Files
logos-cpp-sdk/doctests/cpp-sdk-generator-roundtrip.test.yaml
Dario LipicarandClaude Opus 5 3d322bd315 fix(codegen): LIDL int/uint are 64-bit in the Qt spelling too (#113)
* fix(codegen): LIDL int/uint are 64-bit in the Qt spelling too

lidlTypeToQt mapped BOTH `int` and `uint` to plain `int`. Everywhere else in the
stack a LIDL int/uint is 64-bit — int64_t/uint64_t in C++ impls, i64/u64 in the
Rust SDK — so the Qt spelling broke the one-type-per-LIDL-type rule and lost
data: a Qt consumer reading a `uint` return got a SIGNED 32-bit value, so
anything above 2^31 came back wrong and anything above 2^63 was never
expressible.

int -> qlonglong, uint -> qulonglong, and returnConversion() gains the matching
accessors (toLongLong / toULongLong instead of toInt).

qlonglong/qulonglong rather than qint64/quint64 so the generated introspection
JSON uses the same names Qt's own metaobject normalisation produces — otherwise
a cdylib module's generated `signature` and a legacy module's
QMetaObject-derived one would disagree for the same LIDL type. Nothing looks
these strings up: the only QMetaType::fromName call in the stack is for
"LogosResult".

This changes two generated surfaces: the Qt consumer wrapper signatures and the
introspection JSON. Passing an int argument still converts implicitly, so
callers keep compiling; code that assigns a wrapper's return into an `int`
narrows and may warn, which is the bug being surfaced rather than a regression.

Tests: 168/168, with the type-mapping and client-emitter expectations updated to
the 64-bit spelling.

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

* feat(records): typed C++ structs for Qt consumers

Completes the Qt half of the type mapping this branch started. A `type Foo { … }`
in a contract now generates a real struct in the client header, so a consumer
writes `Status s = client.makeStatus();` instead of digging fields out of a
QVariantMap. One LIDL type, one type per language.

  lidlTypeToQt   - Named -> the record's struct (was QVariant)
                 - [Record] -> QList<Record>, {tstr: Record} -> QMap<QString,
                   Record>. QVariantList CANNOT hold a record without
                   Q_DECLARE_METATYPE, and a typed list is the point.
  client emitter - struct + inline ToVariant/FromVariant per record, emitted
                   before the class; conversions come after all structs so
                   records may reference each other. Recursive, so a field may
                   itself be [Status] or {tstr: bstr}.
                 - records pass by const&, decode on return, and convert at the
                   call site (sync and async)

bstr fields are QByteArray on purpose: logos-protocol's QVariant<->JSON
conversion already materialises the canonical {"_bytes": base64url} form as a
QByteArray and back, so the record conversions stay pure field mapping and binary
survives at any depth with no record-specific bytes handling.

Verified by COMPILING and RUNNING the generated code, not just asserting on text
— the string tests would not have caught either bug this found: [Record] first
mapped to QVariantList (appending a Status to it does not compile) and the decode
lambdas shadowed their accumulator. Extracted the emitted record block for a
contract with a nested record and a bytes field, compiled it against Qt6Core, and
round-tripped Batch -> QVariant -> Batch asserting items[0].port, the QByteArray
blob and the label all survive. Exit 0.

The LidlTypeToQt.NamedType expectation flips from "QVariant" to the struct name,
which is the behaviour change.

Tests: 169/169.

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

* fix(codegen): teach the lp/std consumer wrappers the 64-bit spellings

Caught while checking whether the SDKs are ready for the `any` migration, and it
is a regression THIS BRANCH would otherwise have shipped.

legacy/generator_lib.cpp generates the lp/std consumer wrappers a universal
module uses to call its dependencies. It matches type names against an
allow-list and falls back to QVariant for anything else:

    static const QSet<QString> known = {
        "void","bool","int","double","float","QString", … };
    if (known.contains(base)) return base;
    return QString("QVariant");

Once lidlTypeToQt reports `qlonglong`/`qulonglong`, every LIDL int/uint method
misses that list — so a typed `int` parameter would have silently become an
opaque QVariant in those wrappers. Worse than the truncation this branch set out
to fix, and invisible until someone read the generated header.

Adds the two spellings to both allow-lists, plus the conversions they imply:
QVariant->Qt (toLongLong / toULongLong), the std spellings (int64_t / uint64_t),
the QVariant->std return path, the Qt-style return, and the default-value case.
The existing `int` entries stay for legacy Qt plugins, whose QMetaObject still
reports `int` for a 32-bit parameter.

Tests: 171/171, with the allow-list pinned in both mapping test files —
including that an unknown spelling still falls back to QVariant, so the fallback
itself is not what regressed.

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

* feat(records): typed structs for C++ dependency wrappers

Closes the second record gap. The wrapper every C++ module actually gets for
its dependencies comes from the LEGACY generator (`--dep <name>=<lidl>`), not
the client-stub backend the previous commit taught about records — and there a
contract's `type Status { ... }` reached the consumer as an untyped bag:
QVariant on the Qt surface, LogosMap on the std/lp one. Worse than
inconvenient for a `bstr` field: the caller received the canonical
`{"_bytes": "..."}` envelope and had to know to unwrap it, while Rust and the
stub backend handed back real bytes.

Now, on all three api styles:

    struct Status { uint64_t port{}; std::vector<uint8_t> blob{}; };
    Status getStatus(logos::CallError* err = nullptr);
    std::string describeStatus(const Status& s, ...);
    std::vector<Status> listStatuses(...);

The struct is NESTED in the wrapper class (`InfoModule::Status`) because a
module consuming two deps that each declare a `Status` includes both wrappers
into one translation unit. Conversions are file-local statics in the generated
.cpp, so a Qt-free module's own TUs still never see QVariant or nlohmann.
Records reach parameters, returns, event callbacks, `[Record]` and
`{tstr: Record}` — at any depth, with bytes tagged throughout.

Same commit, the legacy path's half of the 64-bit fix: `lidlTypeExprToQtTypeName`
mapped BOTH int and uint to `int` ("wire-as-int for now"), so a `uint` method on
a dep reached a Qt consumer as a signed 32-bit value and a std/lp consumer as a
signed int64_t. Now qlonglong/qulonglong, matching the spelling the other half
of this PR gave the stub backend. `lpFromJsonExpr` grew the uint64_t branch it
needed — without it a mistyped payload THREW out of nlohmann's implicit
conversion instead of defaulting like every other scalar.

Verified by generating a contract with a record, a record-of-records, a `uint`
above 2^32 and a high-byte `bstr`, then COMPILING the output for qt/std/lp and
round-tripping the emitted conversions:
  - `{"_bytes":"gAH_"}` at every depth, decoding back to the same bytes
  - 4294967296 intact through both directions
  - garbage/missing fields default rather than throw
That compile is what caught the one real bug here: the container decode lambdas
declared `__m`/`__j`, shadowing the record decoder's own locals, so a
map-of-records field read from its own uninitialized local — it compiled with
nothing but a -Wuninitialized warning. Locals are `__acc`/`__src` now, pinned
by a test.

Also: 8 generator tests (one asserting an empty record set leaves every byte
of the output as it was), 179 total green; logos-test-modules builds and tests
green against this generator.

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

* fix(records): async record returns decoded a default-constructed struct

Two correctness holes in the record work, both silent, found while scoping the
cdylib provider side.

1. The async consumer overload emitted `qvariant_cast<Status>(v)` while the sync
   one emitted `StatusFromVariant(_result)`. The wire delivers a QVariantMap and
   no Q_DECLARE_METATYPE is emitted for the struct, so the cast does not fail —
   it returns a DEFAULT-CONSTRUCTED Status and the caller sees empty fields with
   no diagnostic. The sync path being correct is what makes it bad: the same
   call is right or wrong depending only on which overload the caller reached
   for. Async now decodes field by field through the same conversion.

   (The legacy dependency-wrapper generator already did this correctly — this
   was the experimental client-stub backend only.)

2. A record whose ONLY field is a tstr named `_bytes` is wire-identical to 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 decodes as bytes and the struct silently disappears. The ambiguity is
   inherent to the tagged form — the codec's own comment says not to name a map
   key `_bytes` — but the generator can refuse to emit the one shape guaranteed
   to misdecode instead of leaving it to be found at runtime. Both front doors
   (the .lidl client-stub path and the --dep/--interface path) now reject it
   with a message naming the type and the fix.

   A second field disambiguates it (isTaggedBytes requires exactly one key), so
   that shape still generates — verified, not assumed.

181 tests, +2.

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

* feat(cdylib): records, [bstr], typed maps and nested composites on the C++ provider

The C++ cdylib backend could express scalars, `[scalar]`, `any` and an untyped
map. Everything else it rejected BY NAME — `[bstr]`, `[[int]]`, `{tstr: int}`,
and records, which the impl-header parser could not even declare because it
skipped `struct` outright. That is why the ext contract had to be Rust-only.

Four changes, in the order they matter:

  typeSupported()  recurses instead of whitelisting element names, which admits
                   [bstr], [[int]], [Record] and [{tstr: T}] in one rule; a
                   declared record is admitted; a map now REQUIRES a tstr key
                   (it used to `return true` for any map and then silently
                   flatten {int: tstr} to an untyped LogosMap, losing the key
                   type).
  lidlTypeToStdCdylib()  became total. Its `lidlTypeToStd` fallback answers
                   QVariantList / QVariantMap — Qt names in a Qt-FREE
                   translation unit — and only failed to appear because the
                   gate rejected everything that reached it. Widening the gate
                   made that fallback a live leak, so composites now recurse and
                   never reach it.
  <name>_types.h   new: the generated codec, recursive, with a FULL
                   specialization for std::vector<uint8_t> that wins over the
                   generic vector rule — which is what keeps a bstr tagged at
                   any depth instead of becoming a plain array of numbers. One
                   Codec specialization per declared record, field by field,
                   with the field path in the error.
  impl_header_parser  learned `struct` (two passes, because a record field may
                   name another record and the type mapper only answers Named()
                   for an already-registered name — one pass silently typed
                   `Blob inner;` as `any`), std::map<std::string, T>, and
                   recursion into vector elements so std::vector<Blob> is
                   [Blob] rather than falling through to `any`.

Records are only names the contract DECLARES: `void` is not a LIDL builtin, so
`-> void` arrives as Named("void"), and treating every Named as a record is the
exact trap that made the Rust generator emit `-> Void`.

Two things the interface JSON got wrong, both found by running it:

  - it spelled a record `Blob` and a `[Record]` `QList<Blob>`. Those are the
    CONSUMER's names, correct in a generated wrapper where the struct exists —
    but this JSON is the module's getMethods(), read by the host to marshal a
    QVariant, and there is no metatype called `Blob`. The host SIGSEGV'd on the
    first call to any record method. A record IS a variant map at that boundary;
    lidlTypeToQtWire() says so.
  - the types header emitted the structs. Header-first, the author owns them and
    the contract was derived from those very declarations, so it was a
    redefinition. It emits forward declarations and the codec.

Also: `jsonReturn` is set by the front end for any map return, which no longer
implies the C++ type IS nlohmann::json now that a typed map is
std::map<std::string, T> — checking the flag before the spelling emitted
`result.dump()` on a std::map. The spelling decides.

Scalars keep their nlohmann accessor verbatim rather than routing through the
codec: `.get<int64_t>()` TRUNCATES a float instead of throwing, and the
conformance matrix pins that leniency (hostile/int/fractional expects 3 from
3.7). Changing it would silently move behaviour something depends on.

The pinned-rejection test for [bstr] is INVERTED rather than deleted — the cell
it pinned still matters, only its answer changed — plus new tests for the
non-tstr map key rejection and for declared-vs-undeclared records. 183 tests.
Every existing module still builds; test_fullapi_cpp is unchanged.

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

* fix(records): only structs the API mentions become contract types

Teaching the impl-header parser to read `struct` (previous commit) published
EVERY struct in a header as a contract `type`. Two production modules already
carry private helpers:

  openmetrics-module      struct ModuleSource   (namespace scope, internal)
  logos-package-manager   struct PendingAction  (PRIVATE, inside the class)

Both were being published — a module's interface changing as a side effect of
an internal refactor, which is not something deriving a contract from a header
may do. PendingAction was published WRONG as well: its fields carry trailing
`// comments`, the field regex requires a line ending in ';', and the
unmatched fields were silently dropped. A record with a partial field list is
worse than no record, because it looks like a contract.

A struct now earns its place by appearing in a method or event signature —
transitively, since a published record's own fields may name others. Verified
on the real headers: package-manager and openmetrics publish zero types again,
while the ext provider keeps both Blob and Wrapper (Wrapper is reachable only
through Blob's use in a signature). Trailing comments are stripped before the
field match, so no field is dropped.

Two tests over a fixture carrying both an internal namespace-scope struct and a
private in-class one; 185 tests. test-modules and openmetrics both rebuild.

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

* docs(doctests): the generator round-trip now shows 64-bit ints and typed records

The two failing assertions in cpp-sdk-generator-roundtrip were documentation
asserting the OLD behaviour, and both changes are the point of this branch:

  int record(int id, …)          ->  qlonglong record(qulonglong id, …)
  QVariant translate(QVariant p) ->  Point translate(const Point& p, …)

`record`'s `id` is a `uint64_t` in the impl header, so the old signature was
handing a caller a SIGNED 32-bit value for a `uint` — the doc showed the bug.

The surrounding prose was wrong too, not just the expectations, so both blocks
are rewritten rather than patched:

  * Flow 3 now states the mapping as int->qlonglong / uint->qulonglong and says
    why (LIDL int/uint are int64_t/uint64_t in every other binding), pointing at
    `record` as the worked example.
  * The composite section claimed "records and optionals surface as QVariant".
    Records now generate a struct, `[Point]` a QList<Point> and `{tstr: Point}`
    a QMap<QString, Point>; maps of `any`, optionals and bare `any` still cross
    untyped and stay QVariant/QVariantMap — a record has a declared shape, those
    do not. The new text draws that line explicitly.

Expectations added for `struct Point` and `Point bounds(const QList<Point>&…)`
so the record path is pinned in the doc, not just described.

Verified the way CI runs it — `--release-for logos-cpp-sdk=feat/qt-64bit-numerics`,
which is what makes `{release}` resolve to this branch instead of master:
10 passed, 0 failed. (A plain local run builds master and is not
representative — that is why it still showed the old signatures.)

outputs/ regenerated; the diff also picks up unrelated pre-existing drift where
the committed Markdown had fallen behind the spec.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 17:24:49 -03:00

368 lines
19 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-generator`. 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(lidlBytesToJson(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 a synchronous caller plus an `…Async` variant, and each
`event` an `on(...)` subscription. The type mapping is the Qt caller style:
`float64`→`double`, `tstr`→`QString`, `int`→`qlonglong`, `uint`→
`qulonglong`, `bstr`→`QByteArray`, `[tstr]`→`QStringList`, other
arrays→`QVariantList`, and `result`→`LogosResult`.
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 QVariantList& ids"
- "LogosResult reset(const QString& id"
- 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>`. Maps of `any`, optionals (`?T`) and `any` itself
still cross as untyped JSON and stay `QVariantMap` / `QVariant` — a record
has a declared shape, those do 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>`. A map of `any`, an optional and a bare `any` stay
`QVariantMap` / `QVariant` — 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|QVariant 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"
- "QVariant nearest(const Point& p, QVariant limit"