mirror of
https://github.com/logos-co/logos-cpp-sdk.git
synced 2026-09-01 02:01:08 +00:00
* feat(generator): remove --provider-header (interface: "provider")
Every provider now goes through the module-impl C ABI, so the LOGOS_METHOD
dispatch path is gone: parseProviderHeader, generateProviderDispatch,
ParsedMethod and the joinDocLines helper only it used (~300 lines), plus the
test that covered it.
`toQVariantConversion` is NOT removed — it is shared with live emitters — and
its test stays.
The flag is REFUSED rather than dropped. Without that, `--provider-header x.h`
falls through to the plugin-path branch, which reads the flag itself as a
plugin path and reports "Plugin file does not exist: --provider-header" — a
missing-file error for what is really a retired mode. It now exits 2 with a
message pointing at interface: "universal".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(sdk): logos_host_services.h — the C++ veneer over the privileged surface
Phase C1. A Qt-free, header-only wrapper over the three trust-root lp_* calls
A3 added, so capability_module can become an ordinary universal module instead
of a hand-written Qt plugin reaching into TokenManager directly.
Deliberately FREE FUNCTIONS, not a LogosModuleContext seam as the plan
sketched. The grant is process-global per IMAGE (host binary and module cdylib
each link their own logos-protocol, so each has its own grant state and its own
TokenManager), so the gate lives in the caller's own image and there is nothing
per-instance to inject; a context seam would imply the privilege is a property
of one impl object, which it is not. It is also markedly cheaper: a seam would
need a new module-impl C ABI export plus lockstep changes in BOTH codegen paths
(the Qt provider glue and the cdylib wrapper).
constantTimeEquals lives here rather than in each caller: the natural spelling
(a == b) leaks the matching-prefix length through timing, and a trust root
comparing tokens with == is the exact bug this file exists to prevent. Ported
from capability_module's own implementation to std::string.
Two things the tests caught that reading had not:
* lp_inform_module_token_to takes SIX arguments (client, auth_token,
origin_module, module_name, token, timeout_ms), not the three I first wrote.
The wrapper now mirrors it exactly, with the protocol's own default-timeout
semantics documented.
* sdk_tests compiles against logos_headers alone, which carries no protocol
include path. It now resolves logos_protocol.h from LOGOS_PROTOCOL_ROOT,
accepting either the source layout (cpp/) or a package layout (include/) and
failing loudly on neither, rather than hard-coding the one in use today.
The suite deliberately does NOT link logos-protocol: the lp_*-calling wrappers
are `inline` and never ODR-used by these tests, so no protocol symbol is
referenced. That is itself the assertion — the veneer must not drag the
protocol library into a header-only consumer. A future test that calls one will
fail to LINK rather than silently pull it in.
Also documents a real gap found while writing it: lp_token_get performs NO
host-service check, so "token_registry" gates ENUMERATION only. The plan claims
that service covers `lp_token_get(any)`; it does not. Flagged at the call site
rather than papered over — if lookup should be gated, the gate belongs in
lp_token_get.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(generator): emit logos_module_grant_host_services in the cdylib exports
Completes the C1 codegen half. A3 declared this entry point in
logos_module_impl.h and documented that the grant MUST cross the C ABI, but the
generator never emitted it, so nothing could open a module's gates.
The body forwards to lp_grant_host_services in the MODULE's own image, which is
the entire point. Verified that premise on a real built module rather than
taking it from the header comment: test_basic_module_cpp_plugin.dylib DEFINES
25 lp_* symbols and imports zero — logos-protocol is statically linked into
each plugin, so the module's gate state really is its own, and a grant recorded
only in the host would leave lp_token_keys() returning null forever. That
failure is silent: null is indistinguishable from an empty token store.
Emitted unconditionally rather than behind a codegen flag. Which modules are
privileged is the host's decision — it pushes nothing to an ordinary module —
and lp_grant_host_services validates the names and fails closed, so a per-module
flag would only add a second place for declaration and capability to disagree.
The comment states the boundary honestly: this is a declaration-and-audit
mechanism, NOT a defence against a hostile module. The cdylib links
logos-protocol, so its own code can call lp_grant_host_services() directly and
self-grant. What the gate buys is that the privilege is explicit, greppable and
off by default. Isolation between modules rests on process separation, the auth
token, and the target's allowedCallers.
Three tests, one per property that could regress independently: the export
exists; its body actually forwards (a stub returning 0 would make every host
push look successful while both gates stayed shut); and it is emitted for an
ordinary module too, not only for privileged ones.
Confirmed in the built artifact: nm on a real universal module lists
_logos_module_grant_host_services alongside the other seven module-impl exports.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(generator): guard the grant export on the protocol MINOR that added it
The emitted logos_module_grant_host_services calls lp_grant_host_services,
which logos-protocol only gained at MINOR 3. A module built against an older
protocol therefore failed to compile in GENERATED code its author never wrote.
Found by giving logos-template-module a standard flake: its own lock resolves
protocol master, and the build died on `use of undeclared identifier`.
Guarded on LOGOS_PROTOCOL_VERSION_MINOR >= 3. A module built against 0.2 has no
grant entry point at all, which is the same fail-closed state as never being
granted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: point logos_async_result.h at the one LogosModule.cmake
The comment named "logos-plugin-qt/cmake/LogosModule.cmake and its
module-builder twin". There is no twin any more: logos-plugin-qt's copy is
deleted and the file exists once, in logos-module-builder.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(deps): rev-pin logos-protocol at c8bab12 (the trust-root surface)
logos_host_services.h is a veneer over lp_token_keys /
lp_inform_module_token_to / lp_grant_host_services, which landed on
logos-protocol's feat/per-client-token-store branch and are NOT on its
master — master is still LOGOS_PROTOCOL_VERSION_MINOR 2, so the `tests`
check could not compile against the previously locked 03842db.
Rev-pinned in the URL rather than left master-tracking, because
`nix flake update` cannot reach a commit that is not on the tracked
branch. Re-point at master once that branch merges.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(generator): remove --module-dir, and the two dead files it documented
--module-dir walked a directory of BUILT plugins and generated one consumer
wrapper per dependency by dlopen'ing each and reading its QMetaObject. Every
wrapper now comes from a contract instead -- `--general-only` with one
`--dep <name>=<name>.lidl` per dependency -- which builds no dependency plugin
and, unlike introspection, works under cross-compilation.
It is REFUSED rather than ignored, mirroring --provider-header right below it.
Falling through to the dependency LISTING would have exited 0 having generated
nothing: the exact shape that lets a stale caller look green while shipping a
module with no typed API.
No nix build changes as a result -- the flag had no caller left in any of them,
so a store-path diff would be empty either way and would prove nothing. The
only observable difference is what the binary does when handed the flag, so
that is what the new `generator-cli` check asserts, by EXIT CODE:
OK: control - --metadata alone exits 0 and lists dependencies
OK: --module-dir exits non-zero (status=2)
OK: --module-dir fails with the removal diagnostic
OK: --general-only still emits the umbrella
The control matters: without it a non-zero exit could equally mean the binary
is broken. It runs against an EXISTING modules directory too, because the old
code only errored when that directory was missing.
Also deleted, both genuinely dead:
* cpp/compile.sh -- compiles logos_api.cpp, module_proxy.cpp, token_manager.cpp
and six headers, NONE of which exist in this repo any more (they moved to
logos-protocol / logos-qt-host in the host split). The script cannot run.
* docs/docs.md -- 789 lines with zero inbound references anywhere in the
workspace, documenting --module-dir and a cpp/ layout that is gone.
cpp-generator/compile.sh is KEPT: logos-module-builder's LogosModule.cmake:404
still invokes it (`add_custom_target(cpp_generator_build ...)`) on the
LOGOS_CPP_SDK_IS_SOURCE branch, and it builds into exactly the
LOGOS_DEPS_ROOT/build/cpp-generator path that file then reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(generator): the umbrella emitter leaves legacy/, and gets its own mode
`cpp-generator/legacy/` held four things and only one was legacy. The shared
emitter library was misfiled there: `generator_lib.{h,cpp}` is already consumed
by the MODERN `experimental/lidl_gen_client.h` and by all 11 tests under
tests/generator/. `lidl_to_json.{h,cpp}` likewise. Both are now
`cpp-generator/`; `legacy/` is down to `main.cpp` + `legacy_main.h`.
The logos_sdk umbrella (`struct LogosModules`) is not legacy either — it is the
CURRENT typed-dependency surface. `LogosModuleContext::modules()` returns it,
so every universal module that calls a declared dependency goes through it, and
LogosModule.cmake runs `--general-only` for every module build. Yet the only
code that could emit it lived inside the directory the plan wants deleted.
So `cpp-generator/main.cpp` gains `--umbrella`, with `--general-only` routed to
the same implementation and dispatched before the fall-through to legacy_main.
The deps-driven emission needed no rewriting: `makeUmbrella{Header,Source}
FromDeps` were already in generator_lib, and legacy/main.cpp merely wrapped
them in file I/O. -352 lines from legacy/main.cpp (827 -> 475), including the
interface-wrapper helpers that only that branch used.
`--general-only` keeps working identically, because LogosModule.cmake and
logos-basecamp both call it. The alias is guarded on `--metadata`, since
`--general-only` was never a standalone mode — without metadata it fell through
and reported the flag as a missing plugin path, and it still does.
The scraping `writeUmbrellaHeader`/`writeUmbrellaSource` are untouched: they
belong to `generateFromPlugin`, the QPluginLoader introspection path, and die
with it.
Verified byte-identical, which is the whole claim of a relocation. An
adversarial pass built its own pre- and post-change binaries and diffed the
emitted `logos_sdk.{h,cpp}` across 12 real metadata.json files x {qt,lp} x
{--general-only,--umbrella}: 48/48 identical, stdout/stderr/exit included, with
a positive control (qt vs lp) confirming the harness can see a difference. Real
modules then built through logos-module-builder against both binaries with
`diff -r` empty, including the compiled plugin. 266/266 tests pass, and the
pre-change tree also reports 266, so no test was silently dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(sdk): split the SDK by capability, and add the host façade
Host, module-consumer and module-provider are three distinct capabilities. The
SDK exposed them as one target, so a program linked all three regardless of
what it was. Each now gets its own INTERFACE target:
::common logos_json.h, logos_result.h
::consumer logos_lp_client.h, logos_async_result.h
::provider logos_module_context.h, logos_host_services.h
::host logos_host_core.h (new)
`logos_headers` stays as an umbrella over all four, so the ~70 existing
consumers are unaffected — logos_module_context.h alone has 66. Migrate to the
narrow targets when touching a repo; additive first, removal second.
NOTE the misnomer the split exposes: `logos_host_services.h` is MODULE-side
despite its name — it is the veneer a privileged module uses for services the
host granted it — so it belongs to ::provider, not ::host. Renaming it touches
9 files across 5 repos, so it is left for a change that can carry that cascade.
── logos_host_core.h ───────────────────────────────────────────────────────
`logos::host::LogosCore`, a plain RAII wrapper over liblogos' logos_core_* C
API, for the four programs that stand up a core (basecamp, logoscore-cli,
standalone-app, module-viewer). They currently open-code the same calls, and
basecamp had already grown a private wrapper for them.
It is deliberately an ORDINARY class — no codegen, no injection seam, no
void*. LogosModuleContext needs `_logosCoreSetContext_`, SFINAE `maybeSet*`
helpers and a void* round-trip because a module impl is user-authored but
FRAMEWORK-instantiated. A host is main(): it constructs this itself. For the
same reason there is no `modules()` here — the host holds its own LogosModules
from its own generated logos_sdk.h, so this header needs no generated type.
What it earns, each tied to a measured hazard:
* OWNERSHIP. liblogos allocates its char**/char* returns with new[], so
`delete[]` is correct and free() is undefined behaviour. That rule lived in
a comment in one repo's .cpp; it is now in one place.
* ORDERING. Three setters must precede logos_core_start(), stated only in
comments in logos_core.h. They are constructor arguments here, so the
illegal order is not expressible.
* SHAPE. logos_core_get_module_stats() takes no module name and returns one
blob for every module; stats(name) does that parse once.
The logos_core_* ABI is re-declared rather than included: logos-liblogos
depends on logos-cpp-sdk, so including its header would invert the graph. Every
host already hand-declares it; this makes it one declaration instead of four.
15 tests, 281/281 suite total. They define the extern "C" ABI themselves and
allocate exactly as liblogos does, so the ownership rules are exercised rather
than asserted; the ordering test records call order and pins start() as last.
Also fixes a real gap: nix/include.nix carries its own header list, separate
from cpp/CMakeLists.txt's install(FILES), so a new header silently did not ship
in the export layout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(generator): a Qt-typed umbrella that needs no LogosAPI
Splits a consumer's TYPE SURFACE from its TRANSPORT. Until now the qt umbrella
was `explicit LogosModules(LogosAPI* api)` while the lp one was default-
constructible, so "Qt types" implicitly meant "has a LogosAPI" — and a cdylib
module, whose provider surface is the std logos_module_impl.h C ABI and which
holds no LogosAPI anywhere, could not have Qt-typed dependency wrappers at all.
Its generated glue emits `new LogosModules()` unconditionally
(lidl_gen_cdylib.cpp:693), so the combination did not merely misbehave, it did
not compile.
That was a codegen choice, not a law: the wrapper bodies already run over lp_*.
`--binding api|origin` selects it, defaulting to `api`. A second enum rather
than a third ApiStyle value, deliberately: ApiStyle names the type surface and
is switched on by six emitters (makeHeader/makeSource/returnTypeFor/
paramTypeFor/toWireFor/fromWireFor); a "Qt types, explicit origin" member would
force all six to answer a transport question whose honest answer is "same as
Qt" every time. ApiStyle::Lp ignores the new axis — lp is origin-bound by
construction — and that is asserted rather than assumed.
The emitted umbrella bakes metadata.json#name as the origin literal:
LogosModules() : test_fullapi_cpp(QStringLiteral("test_fullapi_qtproxy")) {}
FullApi bind_full_api(const QString& moduleName) {
return FullApi(QStringLiteral("test_fullapi_qtproxy"), moduleName); }
Origin is the CONSUMER's own name and target is the dep — origin first in both
bind_ overloads. This is the load-bearing property: LpBridge::forTarget derives
origin from `api->moduleName()`, and reusing it silently gives a consumer the
caller's identity, which has already preserved a privilege escalation once in
this tree. An empty metadata name is refused at the CLI (exit 6, naming the
file) and emits `#error` in the header: a module that cannot state its identity
must not compile, and must never be handed a blank or borrowed one.
Verified additive on 172 real metadata.json x 2 api-styles = 344 runs, all
producing output, byte-identical old binary vs new. Mutation control: swapping
bind_<iface>'s (origin, moduleName) to (moduleName, origin) fails the suite at
MakeUmbrellaTest.QtExplicitOriginStatesTheConsumersOwnNameEverywhere. 281 -> 286
tests.
Framing worth keeping: the origin is SELF-ASSERTED from the module's own
metadata and is not attested by the transport. That is not a regression —
`api->moduleName()` is equally process-stated — but "explicit origin" means the
module names itself, not that the host vouches for the name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(generator): stop pointing callers at a flag that no longer exists
--backend qt is deleted from logos-qt-generator, and this repo was still
signposting it. main.cpp's refusal said "Use it for --backend qt", and the usage
text advertised --lidl … --backend qt and --from-header … --backend qt. Those
now point at nothing — the exact failure the deletion removes, one repo over.
The refusal names the real replacement chain instead: --backend cdylib here,
then logos-qt-host-generator --backend cdylib for Qt-plugin packaging.
docs/project.md's "Provider Generation" section documented three emitters that
no longer exist; rewritten to state the seam and the two-step pipeline.
docs/spec.md's dataflow diagram showed <name>_qt_glue.h / <name>_dispatch.cpp as
outputs; the diagram is corrected and the sections describing that shape are
marked historical rather than deleted, because the onInit wiring they document
still applies to the cdylib glue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(cpp-generator): merge legacy/ into the generator, dropping its dead half
`legacy/` was never a library with an API surface. Two files, 475 lines, exactly
one exported symbol — `int legacy_main(int, char**)` — with every other
definition `static`, compiled INTO logos-cpp-generator and reached by fallthrough
at the end of main(). So there was nothing to keep separate: it is one mode of
this binary, and it now lives beside the others as plugin_introspect.{cpp,h}
behind `runPluginIntrospectMode()`, named for what it does.
Deleting it was never an option — that premise was checked and refused earlier.
`--general-only` alone has ~10 live callers across 7 repos including the central
module path (buildPlugin.nix:206,211, buildHeaders.nix:221,
LogosModule.cmake:423). The mode is load-bearing; only its packaging was wrong.
110 lines go with the move, all genuinely unreferenced:
* cppStringEscape — zero callers anywhere.
* writeUmbrellaHeader / writeUmbrellaSource and the `if (!moduleOnly)` block
that called them. This is the real prize: a SECOND, directory-SCRAPING
implementation of logos_sdk.{h,cpp}, unreachable in practice because
generate-module-headers.sh:60 always passes --module-only. generator_lib's
deps-driven makeUmbrella*FromDeps is now the only umbrella emitter, so the
two cannot drift.
* a dead `QJsonDocument doc(methods);` and its commented-out use.
With the block gone, `--module-only` suppresses nothing, so the parameter and
its plumbing go too. The FLAG stays tolerated rather than rejected, because
generate-module-headers.sh passes it unconditionally — a comment at the old
parse site says so.
Verified: #default builds, and both checks pass — `generator-cli` (which
exercises the CLI surface, including --general-only) and `tests`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(sdk): make the by-name call path a supported API
The dynamic (by-name) invoke path already existed and was already ungated at
every layer — lp_client_create / lp_invoke in the C ABI, logos::LpClient above
it, and the Qt client above that. Nothing checked a host service; there was no
gate to open. What was missing was the ERGONOMICS, which is what turned a
supported capability into something callers reached around the umbrella to get.
Three additive pieces, no gate touched:
1. LogosModuleContext::moduleName() — the module's own registry name, i.e. the
origin it authenticates as. The typed wrappers bake their origin in at
codegen time; a by-name call has to state one, and a wrong origin
authenticates as nobody and fails far from the call site. Set through a NEW
`_logosCoreSetModuleName_`, deliberately not a fourth parameter on
`_logosCoreSetContext_`: every generated provider calls that signature, so
widening it would break each one until regenerated, for a value the
generator knows statically. Set before the context, so moduleName() is live
inside onContextReady().
2. LogosModules::dynamic(target) on the origin-bound umbrella — the untyped
client, with the origin baked in exactly as the typed members' is, and
cached per target because LpClient owns a connection. The typed members over
metadata.json#dependencies stay the ordinary way to call another module;
this is for the cases whose target is a runtime value (a proxy, a router).
3. LpClient::getMethods() over the already-exported lp_get_methods. Invoke
without introspect is guessing — a caller that cannot ask what exists can
only hardcode, and a wrong guess fails at runtime like a typo.
Verified: #default builds, and both checks pass (tests, generator-cli).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(cdylib): shape-check [any]/{tstr:any} args, and fix the umbrella's includes
TWO fixes; the second is a regression I introduced two commits ago.
1. jsonArgToStd() returned the raw json for LogosList / LogosMap — "untyped JSON
passes through, as it always has". Arity was checked, type was not, so a
scalar reached a `[any]` parameter untouched. A proxy forwarding it through a
Qt-typed consumer then turned "notalist" into
["n","o","t","a","l","i","s","t"], because qvariant_cast reads a QString as a
sequential container — and the downstream provider saw a well-formed array
with nothing left to refuse. Now emits logos::jsonRequireArray /
jsonRequireObject; the throw lands in the dispatch's existing catch as
{"code":"dispatch_failed"}. Bare `any` stays raw, deliberately: it declares
nothing, so there is nothing to check it against.
Measured on the conformance matrix: closes all 8 failing cells (498/22/12/8
-> 500/18/12/2), and a whole-matrix per-cell diff shows exactly 14 status
changes, every one inside the two hostile cases. The other 518 cells are
byte-identical in status and value. The remaining 2 are the fix working — the
C++ cdylib provider now refuses the same input, which cases.json still pins
as lenient via expect_by_provider; that is a registry edit, and known.json's
Q1b already names this exact outcome as the intended fix.
2. The Lp umbrella emitted `logos::LpClient& dynamic(...)` and a
std::map<..., std::unique_ptr<logos::LpClient>> while <map>, <memory> and
logos_lp_client.h were conditional on interfaceNames. A module WITH
dependencies compiled by accident, because <dep>_api.h drags the header in
transitively. A module with NO dependencies and no interfaces includes
nothing else and failed outright with "no type named 'LpClient' in namespace
'logos'". test_fullapi_cpp is exactly that shape.
I shipped that in 1be71bb and did not catch it: I verified #default and both
checks, which are the SDK's own targets, not a dependency-free consumer of
its codegen. `.#logos-test-modules--test_fullapi_cpp` is the case that
exercises it and now builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: use logos-co/setup-nix-cache-action for Nix setup and caching
Replaces the per-repo installer + cachix pair with the shared action, which
installs Nix with the Logos Attic cache (cache.nix.logos.co) preconfigured and
publishes what the job builds — master to the public cache, every other ref to
ci.
Each converted job also gains
environment: ${{ github.ref == 'refs/heads/master' && 'public-cache' || '' }}
because ATTIC_TOKEN_PUBLIC only exists inside that environment. Without it the
secret resolves empty on master and publishing is silently skipped — the job
still passes, so the omission would not show up as a failure.
The action installs Nix itself on every runner, macOS included. That is a
deliberate reversal of the workaround these files carried: the comments here
said cachix/install-nix-action collides with the runner's pre-existing _nixbld
users (eDSRecordAlreadyExists), so DeterminateSystems' installer was used
instead. It no longer reproduces — logos-delivery-module has already been
converted the plain way and its `build-and-test (macos-latest)` leg passes.
Keeping the workaround would have meant a second installer plus a duplicated
substituter/key block in ten files, guarding against something two green runs
say does not happen. If it ever recurs it fails loudly at install, which is
recoverable; the silent-skip above is the failure mode worth engineering
against.
One property is deliberately NOT carried over: the old cachix step ran with
`continue-on-error: true` so a failed cache push could not fail a job whose
tests passed. The action exposes no equivalent, and adding one here would also
swallow genuine setup failures now that the same step installs Nix rather than
only publishing at the end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(deps): track logos-protocol master again
The rev pin on feat/per-client-token-store existed because the trust-root
surface it needed lived only on that branch while protocol master was still
LOGOS_PROTOCOL_VERSION_MINOR 2. Both comments here said to re-point once it
merged; it has (logos-protocol#59), and master is 0.4.0 — MINOR 4, carrying
lp_token_keys, lp_inform_module_token_to, lp_grant_host_services and
TokenManager::forIdentity / isolateIdentity.
That matters beyond compiling: the cdylib glue's grant forwarding is guarded on
MINOR >= 3, so a master pin taken too early would not have failed loudly — it
would have dropped the grant silently. The guard now opens.
Verified against master rather than assumed: #default builds and the checks pass
(cpp-sdk `tests`, plugin-qt `qt-host-generator`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: the generator has no legacy/ directory any more
This should have been part of the merge commit and was not. `docs/project.md`
still listed `legacy/` in the project tree, named `legacy_main()` as the
fallthrough target, and described `--api-style` as being threaded through
`legacy/main.cpp` → `generateFromPlugin` / `writeUmbrellaHeader` — a directory,
a function and two emitters that no longer exist. `docs/spec.md` still pointed
backwards-compatibility at `legacy_main()`.
Corrected to `plugin_introspect.{cpp,h}` / `runPluginIntrospectMode()`, with the
provenance kept rather than erased: the tree entry says what it was and why it
was never a library, because "there used to be a legacy generator" is the
question a reader will actually arrive with.
The umbrella line gets the deletion too — `writeUmbrellaHeader` /
`writeUmbrellaSource` were the second, directory-scraping implementation of
logos_sdk.{h,cpp}, and their absence is the point: `makeUmbrella*FromDeps` is
now the only umbrella emitter, so the two cannot drift.
Two nearby lines were stale independently of that move and are fixed with it:
* "the legacy emitters in tests/generator/" — those tests cover the SHARED
generator_lib emitters (test_make_header/source/umbrella, the type maps),
which are not legacy and never were.
* "consumer wrappers real modules get come from legacy/main.cpp →
generateInterfaceWrappers" — generateInterfaceWrappers is in main.cpp and
was already there, so that path was misattributed before this branch.
Every other use of "legacy" in these docs is left alone: `interface: "legacy"`,
legacy Qt types (QVariantMap / QVariantList / QStringList), legacy Q_INVOKABLE
modules and the legacy consumer path are all real things that still exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: correct generator ownership after the Qt host split
Comments and docs across the repo still claimed this generator emits the Qt
plugin glue, and pointed `--backend qt` users at logos-qt-generator. Neither
is true: the Qt-plugin (provider) glue is emitted by logos-plugin-qt's
logos-qt-host-generator --backend cdylib, on top of the C ABI this tool emits
with --backend cdylib, and logos-qt-generator owns only `consumer` and `ui`
(it refuses the flag too).
Also corrects the LogosModuleContext header, whose comments described the
retired Qt provider path throughout — `<name>_events.cpp` marshalling into a
QVariantList and a provider `onInit` setting the context. The emitted file is
`<name>_events_cdylib.cpp`, it marshals into nlohmann::json, and the C-ABI
export TU installs the callback. The runtime path it named
(runtime_qt/host/module_initializer.cpp) no longer exists.
The only behaviour change is the text of three --backend/--from-header error
messages, which named the wrong tool. Nothing asserts on them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(generator): refuse --backend qt before validating --impl-class
The `backend == "qt"` refusal sat below the --impl-class / --impl-header
requirement checks, so `--backend qt` alone never reached it: it exited 1 with
"Error: --backend qt requires --impl-class <ClassName>", which reads as though
qt would work given one more flag. qt was removed; no flag rescues it.
Hoisted the refusal (and the unsupported-backend error) above those checks.
The cdylib branch returns on every path before this point, so the two checks
could only ever gate a backend that was about to be rejected anyway; they are
dropped rather than left unreachable.
`--backend qt` now exits 6 with the removal message, `--backend bogus` exits 1,
and cdylib is untouched. checks.generator-cli and checks.tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci(doctests): skip the qt-api-events spec, which builds a provider module
This PR removes `logos-cpp-generator --provider-header`. The watcher fixture in
doctests/cpp-sdk-qt-api-events.test.yaml is `interface: "provider"`, so
logos-module-builder reaches "generating provider dispatch (qt_watcher_module)"
and the generator refuses:
Error: --provider-header was removed.
That one build failure cascades through the rest of the spec (install, load,
subscriptionAccepted, greetThrough, greetedCount, lastGreeted), which is the
whole of the red on both ubuntu-latest and macos-latest. notifier_module is
`interface: universal` and builds fine.
Skipped rather than rewritten. The replacement shape that keeps the Qt-typed
dependency wrappers without the retired provider dispatch is
`interface: universal` + `codegen.consumer_api_style: "qt"`, and
logos-module-builder master does not carry that key yet — while this spec pins
the builder to master. It arrives with the B4 stack.
The spec file is kept and annotated, not deleted, because it covers two things
nothing else does: the Qt-TYPED wrapper emission (separately generated code
from the lp path, so lp-path specs cannot catch a bug in it) and a subscription
made from onInit() before the dependency is reachable. Both are UNTESTED until
this is restored — a known, accepted gap recorded in the spec header and in the
workflow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
997 lines
51 KiB
C++
997 lines
51 KiB
C++
#include "lidl_gen_cdylib.h"
|
|
#include "lidl_emit_common.h"
|
|
|
|
#include <QTextStream>
|
|
|
|
#include <functional>
|
|
#include <set>
|
|
#include <string>
|
|
|
|
QString lidlToPascalCase(const QString& name);
|
|
QString lidlTypeToQt(const TypeExpr& te);
|
|
bool lidlIsStdConvertible(const TypeExpr& te);
|
|
|
|
namespace {
|
|
|
|
// The cdylib-supported subset: std-convertible LIDL types only — the same
|
|
// Qt-free set the std apiStyle handled, so any universal module that built
|
|
// under std also builds as a header-first cdylib.
|
|
// The records a contract DECLARES. A `Named` type is a record only if it is in
|
|
// here: `void` is not a LIDL builtin, so `-> void` arrives as Named("void") and
|
|
// treating every Named as a record is how the Rust generator once emitted
|
|
// `-> Void`. Same trap, same guard.
|
|
std::set<std::string> recordNames(const ModuleDecl& module)
|
|
{
|
|
std::set<std::string> out;
|
|
for (const TypeDecl& t : module.types) out.insert(t.name);
|
|
return out;
|
|
}
|
|
|
|
bool isRecord(const TypeExpr& te, const std::set<std::string>& recs)
|
|
{
|
|
return te.kind == TypeExpr::Named && recs.count(te.name) > 0;
|
|
}
|
|
|
|
bool typeSupported(const TypeExpr& te, bool isReturn, const std::set<std::string>& recs)
|
|
{
|
|
if (te.kind == TypeExpr::Primitive) {
|
|
if (te.name == "tstr" || te.name == "bstr" || te.name == "int"
|
|
|| te.name == "uint" || te.name == "float64" || te.name == "bool")
|
|
return true;
|
|
// any (LogosMap/LogosList/json) routes through nlohmann in either
|
|
// direction; result (StdLogosResult) and void only make sense as a
|
|
// return. All Qt-free.
|
|
if (te.name == "any")
|
|
return true;
|
|
if (isReturn && (te.name == "result" || te.name == "void"))
|
|
return true;
|
|
return false;
|
|
}
|
|
// A declared record is a generated struct with a generated codec.
|
|
if (isRecord(te, recs))
|
|
return true;
|
|
// `?T` — supported exactly when its VALUE type is.
|
|
//
|
|
// The value type is checked as a NON-return position on purpose: `result`
|
|
// and `void` are the two spellings that only make sense as a return, and
|
|
// neither can be optional. `void` is the absence of a value, so `?void` is
|
|
// meaningless; `result` already carries its own success/error discriminant,
|
|
// so `?result` would be a second one. `-> ?Point` and `-> ?tstr` are the
|
|
// real optional returns and stay eligible.
|
|
if (te.kind == TypeExpr::Optional) {
|
|
if (te.elements.empty()) return false;
|
|
return typeSupported(optionalValueType(te), /*isReturn=*/false, recs);
|
|
}
|
|
// Recurse rather than whitelisting element names: that admits [bstr],
|
|
// [[int]], [Record] and [{tstr: T}] in one rule, and keeps the gate and
|
|
// the spelling function agreeing about what is expressible.
|
|
if (te.kind == TypeExpr::Array && te.elements.size() == 1)
|
|
return typeSupported(te.elements[0], false, recs);
|
|
// Only tstr keys: the generated codec spells a map as
|
|
// std::map<std::string, T>, so a non-tstr key has no C++ spelling. This
|
|
// used to `return true` for ANY map, which admitted `{int: tstr}` and then
|
|
// silently produced a LogosMap that lost the key type.
|
|
if (te.kind == TypeExpr::Map) {
|
|
if (te.elements.size() != 2) return false;
|
|
const TypeExpr& k = te.elements[0];
|
|
if (!(k.kind == TypeExpr::Primitive && k.name == "tstr")) return false;
|
|
return typeSupported(te.elements[1], false, recs);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Qt-free spelling of a LIDL type (defined below). Forward-declared so the
|
|
// method-param decoder can spell composite `any` containers as their nlohmann
|
|
// aliases instead of Qt containers in this Qt-free TU.
|
|
QString lidlTypeToStdCdylib(const TypeExpr& te, const std::set<std::string>& recs);
|
|
|
|
// json arg expression -> std-typed C++ expression
|
|
// A method argument, decoded into the author's C++ type.
|
|
//
|
|
// EVERY typed value goes through the generated codec, which recurses — so a bstr
|
|
// keeps its canonical tag at ANY depth, a record decodes field by field with a
|
|
// path in the error, and a scalar is checked against its declared type.
|
|
//
|
|
// The scalars used to keep their nlohmann accessor verbatim, and that was the
|
|
// last hole in the type contract on this backend: `.get<uint64_t>()` on -1 wraps
|
|
// to 18446744073709551615 with no exception, so `echoUint(-1)` answered
|
|
// 18446744073709551615 here and `dispatch_failed` on the Rust provider — a
|
|
// silent sign flip on a nominal type, in a contract both providers share.
|
|
// `.get<int64_t>()` on 3.7 likewise truncated to 3 instead of rejecting.
|
|
//
|
|
// The comment that used to sit here justified the leniency by pointing at the
|
|
// conformance matrix cells that pinned it. That was circular: those cells exist
|
|
// to DOCUMENT the divergence, and their own `why` text says the strict behaviour
|
|
// is the correct one. The expectations moved with this change.
|
|
//
|
|
// `any` still passes through untouched — it is the one LIDL type that declares
|
|
// nothing, so there is nothing to check it against.
|
|
QString jsonArgToStd(const TypeExpr& te, const QString& expr, const QString& path,
|
|
const std::set<std::string>& recs)
|
|
{
|
|
// `?T` — decode is LIBERAL, and only by exactly one inhabitant.
|
|
//
|
|
// null decodes to empty; anything else is decoded as T by the SAME decoder a
|
|
// required T would get, so a present-but-wrong value fails with the same
|
|
// message at the same path. Optional widens the domain, it does not switch
|
|
// type checking off.
|
|
if (te.kind == TypeExpr::Optional && !te.elements.empty()) {
|
|
const QString cpp = lidlTypeToStdCdylib(te, recs);
|
|
const TypeExpr& vt = optionalValueType(te);
|
|
// `?any` collapses onto `any` (see lidlTypeToStdCdylib): untyped JSON
|
|
// already carries null, so there is no wrapper to build.
|
|
if (!cpp.startsWith("std::optional<"))
|
|
return jsonArgToStd(vt, expr, path, recs);
|
|
// A scalar `bstr` argument does NOT go through the codec — it gets the
|
|
// lenient bytes decode, so a caller may send the tagged form, a plain
|
|
// string, a number or a byte array. `?bstr` has to keep that, or the
|
|
// identical value would be accepted in a required slot and rejected in
|
|
// an optional one. Test for the empty inhabitant here and wrap.
|
|
if (vt.kind == TypeExpr::Primitive && vt.name == "bstr")
|
|
return "(" + expr + ".is_null() ? " + cpp + "() : " + cpp + "("
|
|
+ jsonArgToStd(vt, expr, path, recs) + "))";
|
|
// Everything else names std::optional<T> and lets
|
|
// Codec<std::optional<T>> map null -> nullopt in one expression.
|
|
return "logos::fromJson<" + cpp + ">(" + expr + ", \"" + path + "\")";
|
|
}
|
|
if (te.kind == TypeExpr::Primitive) {
|
|
if (te.name == "bstr")
|
|
return "logos::bytesFromJsonLenient(" + expr + ", \"" + path + "\")";
|
|
if (te.name == "any") return expr;
|
|
}
|
|
const QString cpp = lidlTypeToStdCdylib(te, recs);
|
|
// `[any]` / `{tstr:any}`. The ELEMENT type is unconstrained, so there is
|
|
// nothing to decode — but the SHAPE is declared, and it used to pass through
|
|
// unchecked ("as it always has"). That let a scalar reach a LogosList
|
|
// parameter, and a proxy forwarding it through a Qt-typed consumer turned
|
|
// "notalist" into ["n","o","t","a","l","i","s","t"] — qvariant_cast reads a
|
|
// QString as a sequential container. The downstream provider then saw a
|
|
// well-formed array and had nothing to refuse.
|
|
//
|
|
// Checked here rather than deeper: LogosList and LogosMap are both aliases
|
|
// of nlohmann::json, so no codec specialization can tell them apart. The
|
|
// value is still handed on unchanged, and the throw lands in the dispatch's
|
|
// existing catch as {"code":"dispatch_failed"} — the same answer, with the
|
|
// same message, that every non-Qt surface already gives.
|
|
if (cpp == "LogosList")
|
|
return "logos::jsonRequireArray(" + expr + ", \"" + path + "\")";
|
|
if (cpp == "LogosMap")
|
|
return "logos::jsonRequireObject(" + expr + ", \"" + path + "\")";
|
|
// A TYPED map does not NAME its C++ type — it hands the compiler a proxy and
|
|
// lets the author's own declaration pick it.
|
|
//
|
|
// `{tstr: T}` has two C++ spellings, std::map and std::unordered_map, and
|
|
// logos_codec.h specializes Codec for both. Naming one of them here would
|
|
// silently make the other a compile error in generated code the author never
|
|
// wrote: `logos::fromJson<std::map<...>>` returns a std::map, and a std::map
|
|
// does not convert to an unordered_map parameter. logos::JsonArg instantiates
|
|
// the conversion with the EXACT parameter type instead, so both spellings
|
|
// decode — through the same Codec, with the same path in the same error.
|
|
//
|
|
// Only maps: every other LIDL type has exactly one C++ spelling here, and
|
|
// JsonArg documents one type it cannot serve (std::optional<X>, whose own
|
|
// converting constructor out-ranks the proxy's conversion operator) — the
|
|
// Optional branch above returns before reaching this line.
|
|
if (te.kind == TypeExpr::Map)
|
|
return "logos::JsonArg(" + expr + ", \"" + path + "\")";
|
|
return "logos::fromJson<" + cpp + ">(" + expr + ", \"" + path + "\")";
|
|
}
|
|
|
|
// std-typed return variable -> json expression
|
|
QString stdReturnToJson(const MethodDecl& md, const QString& var,
|
|
const std::set<std::string>& recs)
|
|
{
|
|
const TypeExpr& te = md.returnType;
|
|
if (md.resultReturn) {
|
|
// StdLogosResult -> the canonical {success, value, error} object
|
|
// (same shape logos_json_convert emits for Qt LogosResult).
|
|
return "lidlResultToJson(" + var + ")";
|
|
}
|
|
// `jsonReturn` is set by the front end for any map/list return, but that no
|
|
// longer implies the C++ type IS nlohmann::json: a TYPED map now spells
|
|
// std::map<std::string, T>. Checking the flag before the spelling emitted
|
|
// `result.dump()` on a std::map. The spelling decides.
|
|
const QString cppRet = lidlTypeToStdCdylib(te, recs);
|
|
if (md.jsonReturn && (cppRet == "LogosMap" || cppRet == "LogosList")) {
|
|
return var; // LogosMap / LogosList are nlohmann::json already
|
|
}
|
|
if (te.kind == TypeExpr::Primitive) {
|
|
if (te.name == "bstr") return "logos::bytesToJson(" + var + ")";
|
|
if (te.name == "any") return var;
|
|
return "nlohmann::json(" + var + ")";
|
|
}
|
|
if (cppRet == "LogosMap" || cppRet == "LogosList")
|
|
return var;
|
|
// Same reason the map ARGUMENT does not name its type: `{tstr: T}` is both
|
|
// std::map and std::unordered_map, so let the return variable's own type be
|
|
// deduced rather than asserting one of them.
|
|
if (te.kind == TypeExpr::Map)
|
|
return "logos::toJson(" + var + ")";
|
|
// `nlohmann::json(v)` would serialize a vector<uint8_t> as a plain number
|
|
// array and a record not at all; the codec keeps bytes tagged at depth.
|
|
return "logos::toJson<" + cppRet + ">(" + var + ")";
|
|
}
|
|
|
|
// Qt-free spelling of a LIDL type. lidlTypeToStd() falls back to Qt containers
|
|
// (QVariant / QVariantMap / QVariantList) for the composite types, but a cdylib
|
|
// TU is Qt-free by definition and typeSupported() admits `any` and maps — so
|
|
// spell those as their nlohmann aliases (LogosMap / LogosList) instead. Without
|
|
// this the events sidecar emits a bare `QVariant` parameter and does not
|
|
// compile.
|
|
QString lidlTypeToStdCdylib(const TypeExpr& te, const std::set<std::string>& recs)
|
|
{
|
|
// `?T` -> std::optional<T>, EXCEPT over the untyped-JSON aliases.
|
|
//
|
|
// LogosMap / LogosList are nlohmann::json, and json already has `null` among
|
|
// its inhabitants — so std::optional<LogosMap> would give `?any` TWO empty
|
|
// spellings (nullopt and json(null)) and make it three-state, which is
|
|
// exactly what R1 forbids. `?any` therefore collapses onto `any`: same two
|
|
// states, one C++ type. (logos-lidl's validator warns on `?any` for the same
|
|
// reason, and the warning is about the spelling, not about this mapping.)
|
|
if (te.kind == TypeExpr::Optional && !te.elements.empty()) {
|
|
const QString inner = lidlTypeToStdCdylib(optionalValueType(te), recs);
|
|
if (inner == "LogosMap" || inner == "LogosList")
|
|
return inner;
|
|
return "std::optional<" + inner + ">";
|
|
}
|
|
if (te.kind == TypeExpr::Primitive && te.name == "any")
|
|
return "LogosMap";
|
|
// `{tstr: any}` and `[any]` keep their nlohmann aliases: every existing
|
|
// universal module spells them that way, and narrowing them would be a
|
|
// source break for no gain (they ARE untyped JSON).
|
|
if (te.kind == TypeExpr::Map && te.elements.size() == 2
|
|
&& te.elements[1].kind == TypeExpr::Primitive && te.elements[1].name == "any")
|
|
return "LogosMap";
|
|
if (te.kind == TypeExpr::Array && te.elements.size() == 1
|
|
&& te.elements[0].kind == TypeExpr::Primitive
|
|
&& te.elements[0].name == "any")
|
|
return "LogosList";
|
|
|
|
// A declared record is its generated struct.
|
|
if (isRecord(te, recs))
|
|
return qs(te.name);
|
|
// Recurse, so [bstr] is std::vector<std::vector<uint8_t>> and {tstr: Blob}
|
|
// is std::map<std::string, Blob>. lidlTypeToStd() would answer QVariantList
|
|
// / QVariantMap here — a Qt name in a Qt-FREE translation unit, which only
|
|
// failed to appear because the gate used to reject these types. Widening
|
|
// the gate makes that fallback a live leak, so composites must never reach
|
|
// it.
|
|
if (te.kind == TypeExpr::Array && te.elements.size() == 1)
|
|
return "std::vector<" + lidlTypeToStdCdylib(te.elements[0], recs) + ">";
|
|
if (te.kind == TypeExpr::Map && te.elements.size() == 2)
|
|
return "std::map<std::string, " + lidlTypeToStdCdylib(te.elements[1], recs) + ">";
|
|
|
|
return lidlTypeToStd(te);
|
|
}
|
|
|
|
// The C++ spelling of a RECORD FIELD, honouring both optionality spellings.
|
|
//
|
|
// `? name: T` and `name: ?T` are the same declaration and must produce
|
|
// byte-identical code (logos-lidl docs/spec.md, "Optionality"). That only holds
|
|
// because fieldIsOptional()/fieldValueType() reconcile them in the frontend —
|
|
// spelling one of the two out here would reintroduce the drift they exist to
|
|
// prevent. Never write `f.optional` or `f.type.kind == Optional` in a backend.
|
|
QString lidlFieldTypeCdylib(const FieldDecl& f, const std::set<std::string>& recs)
|
|
{
|
|
if (!fieldIsOptional(f))
|
|
return lidlTypeToStdCdylib(f.type, recs);
|
|
const QString inner = lidlTypeToStdCdylib(fieldValueType(f), recs);
|
|
// Same collapse as lidlTypeToStdCdylib: untyped JSON already has null.
|
|
if (inner == "LogosMap" || inner == "LogosList")
|
|
return inner;
|
|
return "std::optional<" + inner + ">";
|
|
}
|
|
|
|
// True when anything in the contract is optional — a record field by either
|
|
// spelling, a method parameter or return, or an event parameter. Gates the
|
|
// `#include <optional>` in the generated TUs, so a contract that declares no
|
|
// optional keeps its output byte-for-byte unchanged.
|
|
bool moduleUsesOptional(const ModuleDecl& module)
|
|
{
|
|
std::function<bool(const TypeExpr&)> mentions = [&](const TypeExpr& t) -> bool {
|
|
if (t.kind == TypeExpr::Optional) return true;
|
|
for (const TypeExpr& e : t.elements)
|
|
if (mentions(e)) return true;
|
|
return false;
|
|
};
|
|
for (const TypeDecl& t : module.types)
|
|
for (const FieldDecl& f : t.fields)
|
|
if (fieldIsOptional(f) || mentions(f.type)) return true;
|
|
for (const MethodDecl& md : module.methods) {
|
|
if (mentions(md.returnType)) return true;
|
|
for (const ParamDecl& pd : md.params)
|
|
if (mentions(pd.type)) return true;
|
|
}
|
|
for (const EventDecl& ed : module.events)
|
|
for (const ParamDecl& pd : ed.params)
|
|
if (mentions(pd.type)) return true;
|
|
return false;
|
|
}
|
|
|
|
// True when the module declares at least one `bstr` event parameter — the only
|
|
// reason the events sidecar needs the bytes encoder. Emitting it unconditionally
|
|
// leaves an unused static function (a -Wunused-function warning) in every module
|
|
// whose events carry no binary data.
|
|
// ── The generated codec ─────────────────────────────────────────────────────
|
|
//
|
|
// Emitted into the module's types header so the author's impl class and the
|
|
// generated dispatch share one definition of how a value crosses the wire.
|
|
//
|
|
// This is deliberately the same SHAPE as logos-protocol's logos_codec.h — and
|
|
// it exists as generated code only because that header cannot currently be
|
|
// included here: logos_json.h (which every universal module pulls in for
|
|
// LogosMap) and logos_codec.h both define logos::b64UrlEncode /
|
|
// b64UrlDecode / bytesToJson as inline, so including both in one translation
|
|
// unit is a redefinition error. Unify when that is resolved; the emitted
|
|
// specializations would then be the only generated part.
|
|
//
|
|
// The primary template is intentionally left UNDEFINED: an unsupported T is a
|
|
// compile error naming the type, never a silent default-constructed value.
|
|
// Emits ONE specialization per record the module declares — and nothing else.
|
|
//
|
|
// The generic half (scalars, bstr, the vector/map composition, the error paths)
|
|
// used to be emitted here too, ~186 lines of C++-emitting-C++ that mirrored
|
|
// logos-protocol's logos_codec.h by hand. It no longer is: logos_json.h stopped
|
|
// defining byte helpers that collided with that header, so a module TU can now
|
|
// include the canonical codec directly.
|
|
//
|
|
// That duplication was not free. The two copies had drifted (the emitted integer
|
|
// decode gated on is_number() where the canonical one checked
|
|
// is_number_integer() || is_number_unsigned()), they disagreed on padded base64,
|
|
// and every codec fix had to be written twice or it silently only half-applied.
|
|
//
|
|
// What remains is irreducible: a LIDL `type` is a per-contract struct whose field
|
|
// names and member types exist only in this module's header, and C++17 has no
|
|
// field reflection. Nesting composes for free — Codec<std::vector<Blob>> and
|
|
// deeper come from the shared generic half once Codec<::Blob> exists.
|
|
void emitRecordCodecs(QTextStream& s, const ModuleDecl& module,
|
|
const std::set<std::string>& recs)
|
|
{
|
|
if (module.types.empty()) return;
|
|
// Reopened so the specializations land beside the primary template they
|
|
// specialize. `::Name` because the author's record types are at global
|
|
// scope, while this is namespace logos::detail — without the qualifier the
|
|
// name would resolve inside logos::.
|
|
s << "namespace logos { namespace detail {\n\n";
|
|
// One specialization per declared record. Field order follows the contract.
|
|
for (const TypeDecl& t : module.types) {
|
|
const QString name = qs(t.name);
|
|
s << "template <> struct Codec<::" << name << ", void> {\n";
|
|
s << " static nlohmann::json to(const " << name << "& v) {\n";
|
|
s << " nlohmann::json out = nlohmann::json::object();\n";
|
|
for (const FieldDecl& f : t.fields) {
|
|
const QString ft = lidlFieldTypeCdylib(f, recs);
|
|
const QString fn = qs(f.name);
|
|
if (ft.startsWith("std::optional<")) {
|
|
// ENCODE: a record field is a NAMED slot, so empty is spelled by
|
|
// OMITTING the key — never by writing null. This is the half of
|
|
// the rule Codec<std::optional<T>> deliberately cannot do: a
|
|
// codec only ever sees a VALUE, so it emits the positional
|
|
// spelling (null) and leaves key omission to the one place that
|
|
// knows there IS a key. That place is here.
|
|
//
|
|
// The round trip is therefore CANONICALISING, not identity: a
|
|
// peer that sent `"f": null` gets the key back omitted, and both
|
|
// spellings mean the same state.
|
|
const QString vt = lidlTypeToStdCdylib(fieldValueType(f), recs);
|
|
s << " if (v." << fn << ".has_value())\n";
|
|
s << " out[\"" << fn << "\"] = Codec<" << vt << ">::to(*v."
|
|
<< fn << ");\n";
|
|
} else {
|
|
s << " out[\"" << fn << "\"] = Codec<" << ft << ">::to(v."
|
|
<< fn << ");\n";
|
|
}
|
|
}
|
|
s << " return out;\n }\n";
|
|
s << " static " << name << " from(const nlohmann::json& j, const std::string& path) {\n";
|
|
s << " if (!j.is_object()) detail::typeError(path, \"object\", j);\n";
|
|
s << " " << name << " out;\n";
|
|
for (const FieldDecl& f : t.fields) {
|
|
const QString ft = lidlFieldTypeCdylib(f, recs);
|
|
const QString fn = qs(f.name);
|
|
// A missing field is reported at its own path rather than
|
|
// default-constructed: a record that silently loses a field is the
|
|
// failure mode this whole layer exists to prevent.
|
|
//
|
|
// DECODE needs no optional branch, and that is the point: an absent
|
|
// key is already materialised as null right here, so absent and
|
|
// explicit null arrive at the codec indistinguishable. In an
|
|
// optional field Codec<std::optional<T>> answers nullopt for both;
|
|
// in a required one Codec<T> still rejects both. One expression,
|
|
// both halves of the rule.
|
|
s << " out." << fn << " = Codec<" << ft << ">::from(\n";
|
|
s << " j.contains(\"" << fn << "\") ? j.at(\"" << fn
|
|
<< "\") : nlohmann::json(),\n";
|
|
s << " path + \"." << fn << "\");\n";
|
|
}
|
|
s << " return out;\n }\n};\n\n";
|
|
}
|
|
s << "}} // namespace logos::detail\n\n";
|
|
}
|
|
|
|
// The Qt spelling of what actually crosses the Qt boundary.
|
|
//
|
|
// NOT lidlTypeToQt: that answers the CONSUMER's question ("what type does the
|
|
// caller hold?") and since records became real structs it answers `Blob` /
|
|
// `QList<Blob>`. Those names are correct in a generated consumer wrapper, where
|
|
// the struct exists — but this JSON is the module's getMethods(), read by the
|
|
// host to marshal a QVariant across the plugin boundary, and there is no
|
|
// metatype called `Blob`. Emitting it made the host SIGSEGV on the first call
|
|
// to any record method.
|
|
//
|
|
// A record IS a variant map at that boundary; the struct only exists inside the
|
|
// cdylib.
|
|
QString lidlTypeToQtWire(const TypeExpr& te, const std::set<std::string>& recs)
|
|
{
|
|
if (isRecord(te, recs))
|
|
return "QVariantMap";
|
|
if (te.kind == TypeExpr::Array && te.elements.size() == 1
|
|
&& isRecord(te.elements[0], recs))
|
|
return "QVariantList";
|
|
if (te.kind == TypeExpr::Map && te.elements.size() == 2
|
|
&& isRecord(te.elements[1], recs))
|
|
return "QVariantMap";
|
|
return lidlTypeToQt(te);
|
|
}
|
|
|
|
// True when any event parameter is spelled LogosMap / LogosList, so the sidecar
|
|
// needs <logos_json.h> for those aliases.
|
|
bool hasJsonEventParam(const ModuleDecl& module)
|
|
{
|
|
const std::set<std::string> recs = recordNames(module);
|
|
for (const EventDecl& ed : module.events)
|
|
for (const ParamDecl& pd : ed.params) {
|
|
const QString t = lidlTypeToStdCdylib(pd.type, recs);
|
|
if (t == "LogosMap" || t == "LogosList")
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// The generated base64 codec is GONE — all of it.
|
|
//
|
|
// #117 replaced the emitted generic codec with logos-protocol's logos_codec.h,
|
|
// but left behind the base64 pair it had grown around: an encoder
|
|
// (lidlB64UrlEncode / lidlBytesToJson) and a decoder (lidlB64Idx /
|
|
// lidlBytesFromJson), ~89 emitted lines in every module's export TU. The decoder
|
|
// had no call site at all — every byte parameter had already moved to
|
|
// logos::bytesFromJsonLenient — and the encoder was a byte-for-byte reimplementation
|
|
// of logos::bytesToJson, which is included via <logos_codec.h> in the very same
|
|
// translation unit.
|
|
//
|
|
// A second copy of an encoder is not free: this is the arrangement that let the
|
|
// emitted and canonical halves drift over padded base64 once already, and it is
|
|
// exactly the duplication #117's own comment set out to end. Scalar `bstr` slots
|
|
// now call logos::bytesToJson directly, which is what every composite slot
|
|
// (`[bstr]`, `{tstr: bstr}`, records) has been doing through logos::Codec since
|
|
// #117.
|
|
|
|
void emitInterfaceJson(QTextStream& s, const ModuleDecl& module)
|
|
{
|
|
const std::set<std::string> recs = recordNames(module);
|
|
s << "static nlohmann::json lidlInterfaceJson()\n{\n";
|
|
s << " nlohmann::json methods = nlohmann::json::array();\n";
|
|
for (const MethodDecl& md : module.methods) {
|
|
s << " {\n nlohmann::json obj;\n";
|
|
s << " obj[\"name\"] = \"" << md.name << "\";\n";
|
|
if (!md.description.empty()) {
|
|
QString esc = qs(md.description);
|
|
esc.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
|
|
s << " obj[\"description\"] = \"" << esc << "\";\n";
|
|
}
|
|
QString sig = qs(md.name) + "(";
|
|
for (int i = 0; i < md.params.size(); ++i) {
|
|
sig += lidlTypeToQtWire(md.params[i].type, recs);
|
|
if (i + 1 < md.params.size()) sig += ",";
|
|
}
|
|
sig += ")";
|
|
s << " obj[\"signature\"] = \"" << sig << "\";\n";
|
|
s << " obj[\"returnType\"] = \"" << lidlTypeToQtWire(md.returnType, recs) << "\";\n";
|
|
s << " obj[\"isInvokable\"] = true;\n";
|
|
if (!md.params.empty()) {
|
|
s << " nlohmann::json params = nlohmann::json::array();\n";
|
|
for (const ParamDecl& pd : md.params) {
|
|
s << " params.push_back({{\"type\", \"" << lidlTypeToQtWire(pd.type, recs)
|
|
<< "\"}, {\"name\", \"" << pd.name << "\"}});\n";
|
|
}
|
|
s << " obj[\"parameters\"] = params;\n";
|
|
}
|
|
s << " methods.push_back(obj);\n }\n";
|
|
}
|
|
for (const EventDecl& ed : module.events) {
|
|
s << " {\n nlohmann::json obj;\n";
|
|
s << " obj[\"type\"] = \"event\";\n";
|
|
s << " obj[\"name\"] = \"" << ed.name << "\";\n";
|
|
if (!ed.description.empty()) {
|
|
QString esc = qs(ed.description);
|
|
esc.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
|
|
s << " obj[\"description\"] = \"" << esc << "\";\n";
|
|
}
|
|
QString sig = qs(ed.name) + "(";
|
|
for (int i = 0; i < ed.params.size(); ++i) {
|
|
sig += lidlTypeToQtWire(ed.params[i].type, recs);
|
|
if (i + 1 < ed.params.size()) sig += ",";
|
|
}
|
|
sig += ")";
|
|
s << " obj[\"signature\"] = \"" << sig << "\";\n";
|
|
if (!ed.params.empty()) {
|
|
s << " nlohmann::json params = nlohmann::json::array();\n";
|
|
for (const ParamDecl& pd : ed.params) {
|
|
s << " params.push_back({{\"type\", \"" << lidlTypeToQtWire(pd.type, recs)
|
|
<< "\"}, {\"name\", \"" << pd.name << "\"}});\n";
|
|
}
|
|
s << " obj[\"parameters\"] = params;\n";
|
|
}
|
|
s << " methods.push_back(obj);\n }\n";
|
|
}
|
|
s << " return methods;\n}\n\n";
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool lidlCdylibSupported(const ModuleDecl& module, QString* error)
|
|
{
|
|
const std::set<std::string> recs = recordNames(module);
|
|
for (const MethodDecl& md : module.methods) {
|
|
for (const ParamDecl& pd : md.params) {
|
|
if (!typeSupported(pd.type, /*isReturn=*/false, recs)) {
|
|
if (error)
|
|
*error = QString("method '%1': parameter '%2' has a type outside the "
|
|
"cdylib-supported (Qt-free) subset")
|
|
.arg(qs(md.name), qs(pd.name));
|
|
return false;
|
|
}
|
|
}
|
|
// `void` is not a lidlBuiltinType, so the .lidl parser yields it as a
|
|
// Named type "void" (the impl-header parser writes "-> void"); an empty
|
|
// name is the in-memory void from the header path. Treat both as void.
|
|
const bool voidReturn =
|
|
md.returnType.name == "void"
|
|
|| (md.returnType.kind == TypeExpr::Primitive && md.returnType.name.empty());
|
|
if (!voidReturn && !md.jsonReturn && !md.resultReturn
|
|
&& !typeSupported(md.returnType, /*isReturn=*/true, recs)) {
|
|
if (error)
|
|
*error = QString("method '%1': return type outside the cdylib-supported "
|
|
"(Qt-free) subset").arg(qs(md.name));
|
|
return false;
|
|
}
|
|
}
|
|
for (const EventDecl& ed : module.events) {
|
|
for (const ParamDecl& pd : ed.params) {
|
|
if (!typeSupported(pd.type, /*isReturn=*/false, recs)) {
|
|
if (error)
|
|
*error = QString("event '%1': parameter '%2' has a type outside the "
|
|
"cdylib-supported (Qt-free) subset")
|
|
.arg(qs(ed.name), qs(pd.name));
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
QString lidlMakeTypesHeaderCdylib(const ModuleDecl& module)
|
|
{
|
|
const std::set<std::string> recs = recordNames(module);
|
|
QString c;
|
|
QTextStream s(&c);
|
|
s << "// AUTO-GENERATED by logos-cpp-generator --backend cdylib -- do not edit\n";
|
|
s << "//\n";
|
|
s << "// The record types `" << module.name << "` declares, plus the codec that moves\n";
|
|
s << "// them across the wire. Qt-FREE. The author's impl header includes this and\n";
|
|
s << "// writes the structs directly:\n";
|
|
s << "//\n";
|
|
s << "// Blob echoBlob(const Blob& v);\n";
|
|
s << "//\n";
|
|
s << "// rather than picking fields out of a LogosMap.\n";
|
|
s << "#pragma once\n";
|
|
s << "#include <logos_json.h>\n"; // LogosMap / LogosList aliases
|
|
s << "#include <logos_codec.h>\n"; // logos::Codec — the ONE definition
|
|
s << "#include <cstdint>\n";
|
|
s << "#include <map>\n";
|
|
// Only when the contract actually declares an optional: logos_codec.h
|
|
// already pulls <optional> in, so this is documentation of what the emitted
|
|
// codec names — and emitting it unconditionally would rewrite the types
|
|
// header of every contract that has no optional at all.
|
|
if (moduleUsesOptional(module))
|
|
s << "#include <optional>\n";
|
|
s << "#include <string>\n";
|
|
s << "#include <vector>\n\n";
|
|
|
|
// The structs themselves are the AUTHOR's: this file is included after the
|
|
// impl header, and the contract was derived from those very declarations,
|
|
// so emitting them again is a redefinition error. Only forward
|
|
// declarations, so the codec below can name them in any order.
|
|
if (!module.types.empty()) {
|
|
for (const TypeDecl& t : module.types)
|
|
s << "struct " << qs(t.name) << ";\n";
|
|
s << "\n";
|
|
}
|
|
|
|
emitRecordCodecs(s, module, recs);
|
|
return c;
|
|
}
|
|
|
|
QString lidlMakeModuleImplExports(const ModuleDecl& module,
|
|
const QString& implClass,
|
|
const QString& implHeader)
|
|
{
|
|
const std::set<std::string> recs = recordNames(module);
|
|
QString c;
|
|
QTextStream s(&c);
|
|
|
|
s << "// AUTO-GENERATED by logos-cpp-generator --cdylib -- do not edit\n";
|
|
s << "//\n";
|
|
s << "// The common module-impl C ABI exports (logos_module_impl.h) around the\n";
|
|
s << "// universal impl class `" << implClass << "`. Qt-FREE: compiled into the\n";
|
|
s << "// module's cdylib; the uniform Qt-plugin glue (or a future no-Qt host)\n";
|
|
s << "// drives it exclusively through these symbols.\n";
|
|
s << "#include \"" << implHeader << "\"\n";
|
|
s << "#include \"" << module.name << "_types.h\"\n";
|
|
s << "#include \"logos_module_impl.h\"\n";
|
|
s << "#include \"logos_protocol.h\"\n";
|
|
s << "#include \"logos_module_context.h\"\n";
|
|
s << "#include \"logos_result.h\"\n";
|
|
s << "#include <nlohmann/json.hpp>\n";
|
|
s << "#include <cstdlib>\n";
|
|
s << "#include <cstring>\n";
|
|
s << "#include <atomic>\n";
|
|
s << "#include <map>\n";
|
|
s << "#include <mutex>\n";
|
|
if (moduleUsesOptional(module))
|
|
s << "#include <optional>\n";
|
|
s << "#include <string>\n";
|
|
s << "#include <vector>\n";
|
|
// The Qt-free typed dependency surface: LogosModules (behind modules())
|
|
// built from this module's dependencies (metadata.json#dependencies),
|
|
// calling the lp_* C ABI — no Qt in the cdylib. The umbrella codegen
|
|
// emits logos_sdk.h for every cdylib module (empty when there are no
|
|
// dependencies), so this include is always available.
|
|
s << "#include \"logos_sdk.h\"\n";
|
|
s << "\n";
|
|
|
|
// -- shared statics ------------------------------------------------------
|
|
s << "namespace {\n\n";
|
|
s << implClass << "& lidlImpl()\n{\n static " << implClass << " impl;\n return impl;\n}\n\n";
|
|
s << "logos_module_emit_cb g_emitCb = nullptr;\n";
|
|
s << "void* g_emitUd = nullptr;\n";
|
|
s << "std::mutex g_emitMutex;\n";
|
|
s << "std::mutex g_ctxMutex;\n";
|
|
s << "bool g_ctxStored = false;\n";
|
|
s << "std::string g_ctxPath, g_ctxId, g_ctxPersist;\n";
|
|
s << "std::atomic<bool> g_hookFired{false};\n\n";
|
|
|
|
s << "char* lidlStrdup(const std::string& str)\n{\n";
|
|
s << " char* out = static_cast<char*>(std::malloc(str.size() + 1));\n";
|
|
s << " if (out) std::memcpy(out, str.data(), str.size() + 1);\n";
|
|
s << " return out;\n}\n\n";
|
|
|
|
s << "nlohmann::json lidlResultToJson(const StdLogosResult& r)\n{\n";
|
|
s << " nlohmann::json obj;\n";
|
|
s << " obj[\"success\"] = r.success;\n";
|
|
s << " obj[\"value\"] = r.value;\n";
|
|
s << " obj[\"error\"] = r.error.empty() ? nlohmann::json() : nlohmann::json(r.error);\n";
|
|
s << " return obj;\n}\n\n";
|
|
|
|
emitInterfaceJson(s, module);
|
|
s << "} // namespace\n\n";
|
|
|
|
// -- event wiring (install once, lazily) ---------------------------------
|
|
s << "static void lidlEnsureEmitWiring()\n{\n";
|
|
s << " static std::once_flag once;\n";
|
|
s << " std::call_once(once, []() {\n";
|
|
s << " _logos_codegen_::maybeSetEmitEvent(lidlImpl(),\n";
|
|
s << " [](const std::string& name, void* args) {\n";
|
|
s << " // cdylib events sidecar marshals into nlohmann::json\n";
|
|
s << " const nlohmann::json* payload = static_cast<const nlohmann::json*>(args);\n";
|
|
s << " std::lock_guard<std::mutex> lock(g_emitMutex);\n";
|
|
s << " if (g_emitCb) {\n";
|
|
s << " const std::string dumped = payload ? payload->dump() : \"[]\";\n";
|
|
s << " g_emitCb(name.c_str(), dumped.c_str(), g_emitUd);\n";
|
|
s << " }\n";
|
|
s << " });\n";
|
|
s << " });\n}\n\n";
|
|
|
|
// -- typed dependency surface (modules().<dep>...) -----------------------
|
|
// Wire modules() INDEPENDENTLY of the persistence context. Each dependency
|
|
// client bakes its target+origin at codegen time and creates its lp client
|
|
// lazily on first call, so modules() needs nothing from the context. A
|
|
// module with deps but no STORED context still must have it wired — gating
|
|
// it on the context latch (as it used to be) left m_logosModulesPtr null and
|
|
// segfaulted the first cross-module call when the daemon never delivered a
|
|
// context. No-op for impls that don't derive LogosModuleContext. Fired once
|
|
// from the FIRST lidlTryFireContext (i.e. the first dispatch / set_context /
|
|
// set_emit_callback), before the context-gated early return below.
|
|
s << "static void lidlEnsureModulesWired()\n{\n";
|
|
s << " static std::once_flag once;\n";
|
|
s << " std::call_once(once, []() {\n";
|
|
s << " _logos_codegen_::maybeSetLogosModules(lidlImpl(), new LogosModules());\n";
|
|
s << " });\n}\n\n";
|
|
|
|
// The context ready-latch: stamp the context + fire onContextReady ONCE,
|
|
// as soon as the module is fully wired (context stored AND the emit
|
|
// callback delivered) — at module load, before publication. Hosts that
|
|
// never wire an emit callback still get the hook before first dispatch
|
|
// (requireEmit = false fallback).
|
|
s << "static void lidlTryFireContext(bool requireEmit)\n{\n";
|
|
s << " lidlEnsureEmitWiring();\n";
|
|
s << " lidlEnsureModulesWired();\n";
|
|
s << " if (g_hookFired.load(std::memory_order_acquire)) return;\n";
|
|
s << " std::string path, id, persist;\n";
|
|
s << " {\n";
|
|
s << " std::lock_guard<std::mutex> lock(g_ctxMutex);\n";
|
|
s << " if (!g_ctxStored) return;\n";
|
|
s << " path = g_ctxPath; id = g_ctxId; persist = g_ctxPersist;\n";
|
|
s << " }\n";
|
|
s << " if (requireEmit) {\n";
|
|
s << " std::lock_guard<std::mutex> lock(g_emitMutex);\n";
|
|
s << " if (!g_emitCb) return;\n";
|
|
s << " }\n";
|
|
s << " g_hookFired.store(true, std::memory_order_release);\n";
|
|
// modules() was already wired by lidlEnsureModulesWired() above (before this
|
|
// context-gated early return), so onContextReady can safely call
|
|
// modules().<dep>... / subscribe to dependency events from the hook.
|
|
// The module's own registry name, which the generator knows statically.
|
|
// Set BEFORE the context so moduleName() is live inside onContextReady().
|
|
s << " _logos_codegen_::maybeSetModuleName(lidlImpl(), \"" << module.name << "\");\n";
|
|
s << " _logos_codegen_::maybeSetContext(lidlImpl(), path, id, persist);\n";
|
|
s << "}\n\n";
|
|
|
|
// -- exports -------------------------------------------------------------
|
|
s << "extern \"C\" {\n\n";
|
|
|
|
s << "char* logos_module_dispatch(const char* method, const char* args_json)\n{\n";
|
|
s << " if (!method) return nullptr;\n";
|
|
s << " lidlTryFireContext(false);\n";
|
|
s << " nlohmann::json args = nlohmann::json::array();\n";
|
|
s << " if (args_json && *args_json) {\n";
|
|
s << " args = nlohmann::json::parse(args_json, nullptr, false);\n";
|
|
s << " if (args.is_discarded() || !args.is_array()) return nullptr;\n";
|
|
s << " }\n";
|
|
s << " const std::string m(method);\n";
|
|
s << " try {\n";
|
|
|
|
for (const MethodDecl& md : module.methods) {
|
|
// The arity gate, and the one place the LIBERAL half of the decode rule
|
|
// reaches a POSITIONAL slot.
|
|
//
|
|
// A canonical encoder never changes arity: an empty positional slot is
|
|
// spelled null and still occupies its position. But absent and null are
|
|
// the same state on decode, so an optional trailing argument may also
|
|
// simply not be there. The gate therefore admits anything from the last
|
|
// REQUIRED parameter onwards, and each optional beyond it materialises
|
|
// as null exactly the way an absent record field already does. Below
|
|
// that point nothing changes: a missing required argument is still a
|
|
// hard reject, and a contract with no optional parameters emits the
|
|
// byte-identical `args.size() < <count>` it always did.
|
|
size_t minArgs = 0;
|
|
for (size_t i = 0; i < md.params.size(); ++i)
|
|
if (!paramIsOptional(md.params[i])) minArgs = i + 1;
|
|
s << " if (m == \"" << md.name << "\") {\n";
|
|
// A wrong argument COUNT is reported, not swallowed.
|
|
//
|
|
// This used to be `return nullptr`, and the Qt glue turns a NULL reply
|
|
// into an empty QVariant — indistinguishable from a method that
|
|
// legitimately returned nothing. "You passed 2 of 4 arguments" looked
|
|
// like a successful empty answer.
|
|
//
|
|
// The shape is the one logos-rust-sdk's args::invalid_args() already
|
|
// emits (src/args.rs), so a C++ and a Rust provider answer a malformed
|
|
// call identically — which is what that module's
|
|
// invalid_args_shape_matches_cpp test claims, and what was not true
|
|
// until now. Same three keys, same message text, same `origin`.
|
|
//
|
|
// Emitted only when the method has at least one REQUIRED parameter:
|
|
// `args.size() < 0` is unsigned-compared and always false, so a zero-arg
|
|
// method carried a dead branch (the Rust generator skips it for the same
|
|
// reason).
|
|
if (minArgs > 0) {
|
|
s << " if (args.size() < " << minArgs << ") {\n";
|
|
s << " nlohmann::json err{{\"code\", \"invalid_args\"},\n";
|
|
s << " {\"message\", \"expected " << minArgs
|
|
<< " arguments, got \" + std::to_string(args.size())},\n";
|
|
s << " {\"origin\", \"" << module.name << "\"}};\n";
|
|
s << " return lidlStrdup(err.dump());\n";
|
|
s << " }\n";
|
|
}
|
|
QString call = "lidlImpl()." + qs(md.name) + "(";
|
|
for (size_t i = 0; i < md.params.size(); ++i) {
|
|
const QString expr = (i < minArgs)
|
|
? QString("args.at(%1)").arg(i)
|
|
: QString("(args.size() > %1 ? args.at(%1) : nlohmann::json())").arg(i);
|
|
call += jsonArgToStd(md.params[i].type, expr,
|
|
QString("arg%1").arg(i), recs);
|
|
if (i + 1 < md.params.size()) call += ", ";
|
|
}
|
|
call += ")";
|
|
// `void` parses as a Named type "void" from a .lidl (it isn't a
|
|
// lidlBuiltinType); empty name is the header path's in-memory void.
|
|
const bool voidReturn =
|
|
md.returnType.name == "void"
|
|
|| (md.returnType.kind == TypeExpr::Primitive && md.returnType.name.empty())
|
|
|| lidlTypeToQt(md.returnType) == "void";
|
|
if (voidReturn) {
|
|
s << " " << call << ";\n";
|
|
s << " return lidlStrdup(\"true\");\n";
|
|
} else {
|
|
s << " auto result = " << call << ";\n";
|
|
s << " return lidlStrdup(" << stdReturnToJson(md, "result", recs) << ".dump());\n";
|
|
}
|
|
s << " }\n";
|
|
}
|
|
|
|
s << " } catch (const std::exception& e) {\n";
|
|
s << " nlohmann::json err{{\"code\", \"dispatch_failed\"}, {\"message\", e.what()},\n";
|
|
s << " {\"origin\", \"" << module.name << "\"}};\n";
|
|
s << " return lidlStrdup(err.dump());\n";
|
|
s << " }\n";
|
|
s << " return nullptr; // unknown method\n";
|
|
s << "}\n\n";
|
|
|
|
s << "char* logos_module_get_methods(void)\n{\n";
|
|
s << " return lidlStrdup(lidlInterfaceJson().dump());\n}\n\n";
|
|
|
|
s << "void logos_module_set_context(const char* module_path,\n";
|
|
s << " const char* instance_id,\n";
|
|
s << " const char* instance_persistence_path)\n{\n";
|
|
s << " {\n";
|
|
s << " std::lock_guard<std::mutex> lock(g_ctxMutex);\n";
|
|
s << " g_ctxPath = module_path ? module_path : \"\";\n";
|
|
s << " g_ctxId = instance_id ? instance_id : \"\";\n";
|
|
s << " g_ctxPersist = instance_persistence_path ? instance_persistence_path : \"\";\n";
|
|
s << " g_ctxStored = true;\n";
|
|
s << " }\n";
|
|
s << " lidlTryFireContext(true);\n";
|
|
s << "}\n\n";
|
|
|
|
s << "void logos_module_set_emit_callback(logos_module_emit_cb cb, void* user_data)\n{\n";
|
|
s << " {\n";
|
|
s << " std::lock_guard<std::mutex> lock(g_emitMutex);\n";
|
|
s << " g_emitCb = cb;\n";
|
|
s << " g_emitUd = user_data;\n";
|
|
s << " }\n";
|
|
s << " lidlTryFireContext(true);\n";
|
|
s << "}\n\n";
|
|
|
|
s << "int logos_module_accept_token(const char* module_name, const char* token)\n{\n";
|
|
s << " if (!module_name || !token) return -1;\n";
|
|
s << " // Seed the protocol's shared TokenManager so this module's OUTBOUND\n";
|
|
s << " // lp_client (modules().<dep>...) can authenticate calls. In\n";
|
|
s << " // particular the capability_module bootstrap token the host\n";
|
|
s << " // delivers at load lets the automatic requestModule flow fetch a\n";
|
|
s << " // per-target token on the first cross-module call. lp_token_save\n";
|
|
s << " // writes the same TokenManager::instance() the lp_client reads.\n";
|
|
s << " return lp_token_save(module_name, token);\n}\n\n";
|
|
|
|
// Guarded on the protocol MINOR that introduced the trust-root surface
|
|
// (0.3). The emitted module must still COMPILE against an older
|
|
// logos-protocol, which has neither lp_grant_host_services nor the
|
|
// logos_module_impl.h declaration — a module built against 0.2 simply has
|
|
// no grant entry point, which is the same fail-closed state as never being
|
|
// granted. Without this an older protocol is a hard compile error in
|
|
// generated code the author never sees.
|
|
s << "#if defined(LOGOS_PROTOCOL_VERSION_MINOR) && LOGOS_PROTOCOL_VERSION_MINOR >= 3\n";
|
|
s << "int logos_module_grant_host_services(const char* services_json)\n{\n";
|
|
s << " // Route the host's grant into THIS image's gate state.\n";
|
|
s << " //\n";
|
|
s << " // The grant has to travel over the C ABI rather than being\n";
|
|
s << " // recorded once by the host, and that is the whole reason this\n";
|
|
s << " // export exists: the host binary and this cdylib each link their\n";
|
|
s << " // own copy of logos-protocol, so each has its own process-global\n";
|
|
s << " // grant state, exactly as each has its own TokenManager. A grant\n";
|
|
s << " // the host records for itself is invisible to the gate a\n";
|
|
s << " // lp_token_keys() call checks HERE, so a gate 'simplified' into\n";
|
|
s << " // the host would silently never fire.\n";
|
|
s << " //\n";
|
|
s << " // Emitted unconditionally, for every module, rather than behind a\n";
|
|
s << " // codegen flag: which modules are privileged is the HOST's\n";
|
|
s << " // decision (it chooses what to push, and pushes nothing to an\n";
|
|
s << " // ordinary module), and lp_grant_host_services itself validates\n";
|
|
s << " // the names and fails closed. A per-module flag would only add a\n";
|
|
s << " // second place for the two to disagree.\n";
|
|
s << " //\n";
|
|
s << " // NOTE this is a declaration-and-audit boundary, NOT a defence\n";
|
|
s << " // against a hostile module: this cdylib links logos-protocol, so\n";
|
|
s << " // its own code can call lp_grant_host_services() directly and\n";
|
|
s << " // self-grant. What the gate buys is that the privilege is\n";
|
|
s << " // explicit, greppable and off by default, so no module acquires\n";
|
|
s << " // it by accident. Isolation between modules rests on process\n";
|
|
s << " // separation, the auth token and the target's allowedCallers.\n";
|
|
s << " return lp_grant_host_services(services_json);\n}\n";
|
|
s << "#endif\n\n";
|
|
|
|
s << "const char* logos_module_get_protocol_version(void)\n{\n";
|
|
s << " return LOGOS_PROTOCOL_VERSION_STRING;\n}\n\n";
|
|
|
|
s << "void logos_module_string_free(char* str)\n{\n";
|
|
s << " std::free(str);\n}\n\n";
|
|
|
|
s << "} // extern \"C\"\n";
|
|
return c;
|
|
}
|
|
|
|
QString lidlMakeEventsSourceCdylib(const ModuleDecl& module,
|
|
const QString& implClass,
|
|
const QString& implHeader)
|
|
{
|
|
QString c;
|
|
QTextStream s(&c);
|
|
s << "// AUTO-GENERATED by logos-cpp-generator --cdylib -- do not edit\n";
|
|
s << "// Typed `logos_events:` bodies, cdylib flavor: marshal into\n";
|
|
s << "// nlohmann::json and route through LogosModuleContext::emitEventImpl_\n";
|
|
s << "// (the export wrapper forwards to the host's emit callback).\n";
|
|
const std::set<std::string> recsEv = recordNames(module);
|
|
s << "#include \"" << implHeader << "\"\n";
|
|
s << "#include \"" << module.name << "_types.h\"\n";
|
|
s << "#include <nlohmann/json.hpp>\n\n";
|
|
s << "#include <cstdint>\n";
|
|
s << "#include <map>\n";
|
|
if (moduleUsesOptional(module))
|
|
s << "#include <optional>\n";
|
|
s << "#include <string>\n";
|
|
s << "#include <vector>\n";
|
|
// LogosMap / LogosList (nlohmann aliases) appear in the emitted signatures
|
|
// whenever an event carries a map or an `any` payload.
|
|
if (hasJsonEventParam(module))
|
|
s << "#include <logos_json.h>\n";
|
|
s << "\n";
|
|
|
|
// No local bytes encoder any more, and so no hasBytesEventParam() gate for
|
|
// it either: a `bstr` event parameter calls logos::bytesToJson, which the
|
|
// <logos_codec.h> pulled in by "<module>_types.h" above already provides.
|
|
// The gate existed only to keep the emitted copy from sitting unused in
|
|
// modules whose events carry no binary data.
|
|
|
|
for (const EventDecl& ed : module.events) {
|
|
s << "void " << implClass << "::" << ed.name << "(";
|
|
for (int i = 0; i < ed.params.size(); ++i) {
|
|
const QString stdType = lidlTypeToStdCdylib(ed.params[i].type, recsEv);
|
|
// Must match the author's declaration in the `logos_events:` block:
|
|
// the non-scalar types are conventionally taken by const-ref there.
|
|
// Records and std::map belong in that set too — they are structs and
|
|
// containers, and emitting them BY VALUE makes the generated
|
|
// definition not match the author's declaration, which is a compile
|
|
// error naming a parameter type mismatch rather than anything
|
|
// helpful.
|
|
if (stdType == "std::string" || stdType.startsWith("std::vector")
|
|
|| stdType.startsWith("std::map")
|
|
|| stdType.startsWith("std::optional")
|
|
|| isRecord(ed.params[i].type, recsEv)
|
|
|| stdType == "LogosMap" || stdType == "LogosList")
|
|
s << "const " << stdType << "& " << ed.params[i].name;
|
|
else
|
|
s << stdType << " " << ed.params[i].name;
|
|
if (i + 1 < ed.params.size()) s << ", ";
|
|
}
|
|
s << ")\n{\n";
|
|
s << " nlohmann::json args = nlohmann::json::array();\n";
|
|
for (const ParamDecl& pd : ed.params) {
|
|
const QString evStd = lidlTypeToStdCdylib(pd.type, recsEv);
|
|
// A record or a composite carrying bytes rides the generated codec,
|
|
// exactly like a method return — otherwise an event payload would be
|
|
// the one place a bstr silently loses its tag.
|
|
//
|
|
// An optional joins them: an event parameter is a POSITIONAL slot,
|
|
// so empty is spelled null and the argument list keeps its length.
|
|
// Codec<std::optional<T>>::to answers exactly that. (`?any` collapsed
|
|
// to LogosMap above and is excluded by the same guard the untyped
|
|
// aliases always were.)
|
|
if (evStd != "LogosMap" && evStd != "LogosList"
|
|
&& (isRecord(pd.type, recsEv)
|
|
|| pd.type.kind == TypeExpr::Array || pd.type.kind == TypeExpr::Map
|
|
|| pd.type.kind == TypeExpr::Optional)) {
|
|
s << " args.push_back(logos::toJson<" << evStd << ">("
|
|
<< pd.name << "));\n";
|
|
continue;
|
|
}
|
|
if (pd.type.kind == TypeExpr::Primitive && pd.type.name == "bstr")
|
|
s << " args.push_back(logos::bytesToJson(" << pd.name << "));\n";
|
|
else
|
|
s << " args.push_back(" << pd.name << ");\n";
|
|
}
|
|
s << " emitEventImpl_(\"" << ed.name << "\", &args);\n";
|
|
s << "}\n\n";
|
|
}
|
|
return c;
|
|
}
|