From 1e516c02cec71b35a8ac97a6c23973c4ce59c8fb Mon Sep 17 00:00:00 2001 From: Dario Gabriel Lipicar Date: Thu, 20 Aug 2026 12:40:30 -0300 Subject: [PATCH] feat(generator): emit the derived module identity methods Run logos-lidl's injectIdentityMethods() on every ModuleDecl this generator emits code from, and give the cdylib dispatch a body for the two methods it adds. Injection happens at EMISSION points, never at artifact points: * generateInterfaceWrappers -- one load point covering both --dep and --interface, so a consumer sees name()/version() on every dependency and bound interface; * --from-header --backend cdylib and --lidl --backend cdylib, so the provider answers them; * NOT --header-to-lidl, which writes the published contract. The distinction is belt-and-braces rather than load-bearing: the injected methods are `derived` and lidlSerialize omits those, so the .lidl a --from-header build writes stays byte-identical to what --header-to-lidl writes for the same header. The dispatch emits a literal for a derived identity method instead of the usual lidlImpl().(...) -- the author's impl class has no such member, so delegating would not compile. The literal is the module's own name and version, so it cannot drift from the metadata the module was built with. A module that declares name() itself is not derived and still reaches its impl. 290/290 tests pass, 4 new: that the emitted literal is the module's OWN version (a test at 1.0.0 could not tell a correct generator from one that fell back), that identity is listed for introspection as well as dispatched, that an author's own name() still reaches the impl, and that a versionless declaration falls back rather than emitting "" -- which would read as a failed call. Requires logos-lidl#10; flake.lock pins that branch until it merges. Co-Authored-By: Claude Opus 5 --- cpp-generator/experimental/lidl_compat.h | 22 +++++ .../experimental/lidl_gen_cdylib.cpp | 14 +++ cpp-generator/main.cpp | 36 +++++++- flake.lock | 6 +- tests/experimental/test_lidl_gen_cdylib.cpp | 86 +++++++++++++++++++ 5 files changed, 159 insertions(+), 5 deletions(-) diff --git a/cpp-generator/experimental/lidl_compat.h b/cpp-generator/experimental/lidl_compat.h index 9e0372c..f702c7b 100644 --- a/cpp-generator/experimental/lidl_compat.h +++ b/cpp-generator/experimental/lidl_compat.h @@ -10,6 +10,7 @@ // gen_cdylib) stay here — they are the C++/Qt-specific parts. #include "lidl/ast.hpp" +#include "lidl/identity.hpp" #include "lidl/parser.hpp" #include "lidl/serializer.hpp" #include "lidl/validator.hpp" @@ -71,6 +72,27 @@ inline lidl::ValidationResult lidlValidate(const ModuleDecl& module) return lidl::validate(module); } +// Add the derived module identity methods — name() and version() — to a +// ModuleDecl that is about to have CODE emitted from it. Returns false and +// fills `error` when the module declares one of those reserved names with an +// incompatible signature. +// +// Emission only. Never call this before serializing a .lidl: the published +// contract stays exactly what the author wrote, and the provider and every +// consumer each add the identity methods from this one function, so the two +// sides cannot disagree about them. Injecting into the artifact instead would +// make `--header-to-lidl` and `--from-header` disagree about the same module, +// and would make the two methods indistinguishable from author-declared ones. +inline bool lidlInjectIdentity(ModuleDecl& module, QString* error) +{ + const lidl::IdentityInjection r = lidl::injectIdentityMethods(module); + if (r.hasError()) { + if (error) *error = qs(r.error); + return false; + } + return true; +} + // A record whose ONLY field is a `tstr` named `_bytes` is indistinguishable on // the wire from a canonical tagged byte string: `isTaggedBytes()` is checked // BEFORE `is_object()` in both logos_codec.h and logos_json_convert.cpp, so diff --git a/cpp-generator/experimental/lidl_gen_cdylib.cpp b/cpp-generator/experimental/lidl_gen_cdylib.cpp index 3bce799..eb5d383 100644 --- a/cpp-generator/experimental/lidl_gen_cdylib.cpp +++ b/cpp-generator/experimental/lidl_gen_cdylib.cpp @@ -794,6 +794,20 @@ QString lidlMakeModuleImplExports(const ModuleDecl& module, s << " return lidlStrdup(err.dump());\n"; s << " }\n"; } + // A derived method (lidl/identity.hpp) has no member on the impl class + // to call — the generator owns its body. name()/version() answer from + // the module declaration, which the builder derives from metadata.json, + // so the reported value cannot drift from the built one. + if (md.derived && lidl::isIdentityMethod(md.name)) { + const QString literal = md.name == lidl::kIdentityName + ? qs(module.name) + : (module.version.empty() ? QStringLiteral("1.0.0") : qs(module.version)); + s << " auto result = std::string(\"" << literal << "\");\n"; + s << " return lidlStrdup(" << stdReturnToJson(md, "result", recs) + << ".dump());\n"; + s << " }\n"; + continue; + } QString call = "lidlImpl()." + qs(md.name) + "("; for (size_t i = 0; i < md.params.size(); ++i) { const QString expr = (i < minArgs) diff --git a/cpp-generator/main.cpp b/cpp-generator/main.cpp index 3e1c36e..58e7a02 100644 --- a/cpp-generator/main.cpp +++ b/cpp-generator/main.cpp @@ -168,6 +168,18 @@ static bool generateInterfaceWrappers(const QVector& ifaces, } } + { + // Consumers see name()/version() on every dependency and bound + // interface. Added here rather than read from the .lidl: the + // artifact carries only what the author wrote, and the provider + // adds the same two methods from the same function. + QString idErr; + if (!lidlInjectIdentity(mod, &idErr)) { + err << spec.path << ": " << idErr << "\n"; + return false; + } + } + noteOptionalPositionalSlots(mod, spec.path, err); const QString className = toPascalCase(spec.name); @@ -591,7 +603,14 @@ int main(int argc, char* argv[]) return 4; } - const ModuleDecl& mod = pr.module; + ModuleDecl mod = pr.module; + { + QString idErr; + if (!lidlInjectIdentity(mod, &idErr)) { + err << headerPath << ": " << idErr << "\n"; + return 4; + } + } QString genDirPath = outputDir.isEmpty() ? QDir::current().filePath("generated") : outputDir; @@ -617,6 +636,9 @@ int main(int argc, char* argv[]) if (!mod.events.empty()) outs.append({qs(mod.name) + "_events_cdylib.cpp", lidlMakeEventsSourceCdylib(mod, implClass, implHeader)}); + // Identity methods are `derived`, and lidlSerialize omits + // those — so this stays byte-identical to what + // --header-to-lidl writes for the same header. outs.append({qs(mod.name) + ".lidl", lidlSerialize(mod)}); for (const Out& o : outs) { const QString abs = QDir(genDirPath).filePath(o.file); @@ -692,7 +714,17 @@ int main(int argc, char* argv[]) << " (line " << pr.errorLine << ")\n"; return 4; } - const ModuleDecl& mod = pr.module; + ModuleDecl mod = pr.module; + { + // Contract-first: the committed .lidl is untouched; the + // provider's dispatch and method listing gain the identity + // methods the same way every consumer does. + QString idErr; + if (!lidlInjectIdentity(mod, &idErr)) { + err << lidlPath << ": " << idErr << "\n"; + return 4; + } + } QString cdErr; if (!lidlCdylibSupported(mod, &cdErr)) { err << "Error: module not cdylib-eligible: " << cdErr << "\n"; diff --git a/flake.lock b/flake.lock index 3044656..596b44b 100644 --- a/flake.lock +++ b/flake.lock @@ -12,11 +12,11 @@ ] }, "locked": { - "lastModified": 1786415321, - "narHash": "sha256-Oe98SavQSVGBIY7WIc8RQ5l+Bl4KLsdmjb+PyscfdNw=", + "lastModified": 1787240151, + "narHash": "sha256-WHmisUvYA8DQ2ZxSF2mM2INTEuxfyF07ypPOf+lOik4=", "owner": "logos-co", "repo": "logos-lidl", - "rev": "ffeebf2e90fa0c65e8c486988271fe0ca029d1e1", + "rev": "dd3440183b14a9d94758ff3c5a322875cc6a3147", "type": "github" }, "original": { diff --git a/tests/experimental/test_lidl_gen_cdylib.cpp b/tests/experimental/test_lidl_gen_cdylib.cpp index 93df922..4a9514e 100644 --- a/tests/experimental/test_lidl_gen_cdylib.cpp +++ b/tests/experimental/test_lidl_gen_cdylib.cpp @@ -694,3 +694,89 @@ TEST(LidlGenCdylib, GrantExportIsEmittedForEveryModuleNotJustPrivilegedOnes) EXPECT_TRUE(src.contains("logos_module_grant_host_services")) << src.toStdString(); } + +// --- Module identity --------------------------------------------------------- +// +// name()/version() are injected into the contract by the frontend +// (lidl/identity.hpp) and marked `derived`. The dispatch must answer them from +// the module DECLARATION -- the impl class has no such member, so delegating +// would not compile, and reading anything else would let the reported value +// drift from the metadata the module was built with. + +namespace { + +ModuleDecl moduleWithIdentity(const char* name, const char* version) +{ + ModuleDecl m; + m.name = name; + m.version = version; + lidl::injectIdentityMethods(m); + return m; +} + +QString implExportsFor(const ModuleDecl& m) +{ + return lidlMakeModuleImplExports(m, "SomeImpl", "some_impl.h"); +} + +} // namespace + +TEST(LidlGenCdylib, IdentityMethodsAnswerFromTheModuleDeclaration) +{ + const QString src = implExportsFor(moduleWithIdentity("weather_module", "2.4.1")); + + // The literal is the module's OWN version, not a default. A generator that + // fell back to "1.0.0" here would be indistinguishable from a correct one + // on the many modules that happen to be at 1.0.0. + EXPECT_TRUE(src.contains("if (m == \"name\")")) << src.toStdString(); + EXPECT_TRUE(src.contains("std::string(\"weather_module\")")) << src.toStdString(); + EXPECT_TRUE(src.contains("if (m == \"version\")")) << src.toStdString(); + EXPECT_TRUE(src.contains("std::string(\"2.4.1\")")) << src.toStdString(); + + // ...and never through the impl class, which has no such member. + EXPECT_FALSE(src.contains("lidlImpl().name(")) << src.toStdString(); + EXPECT_FALSE(src.contains("lidlImpl().version(")) << src.toStdString(); +} + +TEST(LidlGenCdylib, IdentityMethodsAreListedForIntrospection) +{ + // `lm methods` and every untyped caller read this listing, so an identity + // method that dispatches but is not advertised is only half present. + const QString src = implExportsFor(moduleWithIdentity("weather_module", "2.4.1")); + EXPECT_TRUE(src.contains("obj[\"name\"] = \"name\"")) << src.toStdString(); + EXPECT_TRUE(src.contains("obj[\"name\"] = \"version\"")) << src.toStdString(); + EXPECT_TRUE(src.contains("obj[\"signature\"] = \"name()\"")) << src.toStdString(); +} + +TEST(LidlGenCdylib, AnAuthorsOwnIdentityMethodStillReachesTheImpl) +{ + // A module MAY implement name() itself (logos-delivery-module does). It is + // then not `derived`, so it must dispatch like any other author method -- + // silently shadowing it with a generated literal would change behaviour. + ModuleDecl m; + m.name = "delivery_module"; + m.version = "1.0.0"; + MethodDecl mine; + mine.name = "name"; + mine.returnType = prim("tstr"); + m.methods.push_back(mine); + lidl::injectIdentityMethods(m); + + const QString src = implExportsFor(m); + EXPECT_TRUE(src.contains("lidlImpl().name(")) << src.toStdString(); + EXPECT_FALSE(src.contains("std::string(\"delivery_module\")")) << src.toStdString(); + // version() was still injected, and is still generated. + EXPECT_TRUE(src.contains("std::string(\"1.0.0\")")) << src.toStdString(); +} + +TEST(LidlGenCdylib, AVersionlessModuleFallsBackRatherThanEmittingEmpty) +{ + // A ModuleDecl with no version reaches here from a synthetic/interface + // contract. Emitting "" would make version() answer the empty string, + // which reads as a failure rather than as "unversioned". + ModuleDecl m; + m.name = "bare_module"; + lidl::injectIdentityMethods(m); + EXPECT_TRUE(implExportsFor(m).contains("std::string(\"1.0.0\")")) + << implExportsFor(m).toStdString(); +}