From fde0f6fccbafdc33f685bdd6ae63ff38f2b5979c Mon Sep 17 00:00:00 2001 From: osmaczko <33099791+osmaczko@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:18:18 +0200 Subject: [PATCH] fix: read dependency entries declared in object form (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: read dependency entries declared in object form The manifest schema lets a dependency entry be an object carrying the name alongside the constraints an installer resolves it by, but every reader took the element as a plain string and skipped what came back empty, so an object entry disappeared: the module it names was left out of the generated LogosModules aggregate, and every call through it failed to compile. The rule lives in one place now, since the copies of it are how the gap spread. It ships in share/lidl-frontend alongside the parser that includes it, which consumers compile from there. * fix: read every dependency entry through one pass over the array The object form reached the umbrella's members and constructor but not its includes: that emitter still read each element as a plain string, so a module declared in object form came out as a member whose type was never included, and the aggregate no longer compiled. It is the Qt-free umbrella, which is what every universal core module and every cdylib module generates, so the form the previous commit set out to support failed there in a new way rather than working. Reading the array element by element is what let one pass disagree with the next, so no reader does that any more: dependencyNames() answers what an array declares, once, and the emitters walk names. That leaves the entry form knowable in exactly one place, and the includes and members of an aggregate can no longer be built from different answers. The umbrella emission moves to generator_lib alongside the per-module wrapper emitters it mirrors, returning the text instead of writing it, so what it generates can be asserted on directly; main.cpp writes what it returns. Output for string-form dependencies is byte-identical in both API styles, with and without interface dependencies. The listing mode (`--metadata` with no `--module-dir`) went the same way — it was the last reader still deciding on its own. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Dario Gabriel Lipicar Co-authored-by: Claude Opus 5 --- .../experimental/impl_header_parser.cpp | 8 +- cpp-generator/legacy/generator_lib.cpp | 127 ++++++++++++++ cpp-generator/legacy/generator_lib.h | 14 ++ cpp-generator/legacy/main.cpp | 161 ++---------------- cpp-generator/metadata_dependencies.h | 45 +++++ nix/bin.nix | 1 + .../fixtures/object_deps_metadata.json | 15 ++ .../experimental/test_impl_header_parser.cpp | 19 +++ tests/generator/CMakeLists.txt | 1 + tests/generator/test_make_umbrella.cpp | 129 ++++++++++++++ 10 files changed, 367 insertions(+), 153 deletions(-) create mode 100644 cpp-generator/metadata_dependencies.h create mode 100644 tests/experimental/fixtures/object_deps_metadata.json create mode 100644 tests/generator/test_make_umbrella.cpp diff --git a/cpp-generator/experimental/impl_header_parser.cpp b/cpp-generator/experimental/impl_header_parser.cpp index d129016..5c43639 100644 --- a/cpp-generator/experimental/impl_header_parser.cpp +++ b/cpp-generator/experimental/impl_header_parser.cpp @@ -1,5 +1,7 @@ #include "impl_header_parser.h" +#include "metadata_dependencies.h" + #include #include #include @@ -392,9 +394,9 @@ ImplParseResult parseImplHeader(const QString& headerPath, result.module.version = obj.value("version").toString().toStdString(); result.module.description = obj.value("description").toString().toStdString(); result.module.category = obj.value("category").toString().toStdString(); - QJsonArray deps = obj.value("dependencies").toArray(); - for (const QJsonValue& v : deps) - result.module.depends.push_back(v.toString().toStdString()); + const QJsonArray deps = obj.value("dependencies").toArray(); + for (const QString& depName : dependencyNames(deps)) + result.module.depends.push_back(depName.toStdString()); // Read events declared in metadata.json QJsonArray events = obj.value("events").toArray(); diff --git a/cpp-generator/legacy/generator_lib.cpp b/cpp-generator/legacy/generator_lib.cpp index 12df051..f71a8c0 100644 --- a/cpp-generator/legacy/generator_lib.cpp +++ b/cpp-generator/legacy/generator_lib.cpp @@ -1,5 +1,7 @@ #include "generator_lib.h" +#include "metadata_dependencies.h" + #include #include #include @@ -1262,3 +1264,128 @@ QString makeSourceLp(const QString& moduleName, const QString& className, const } return c; } + +// ── Umbrella (logos_sdk.h / logos_sdk.cpp) over a module's dependencies ────── + +QString makeUmbrellaHeaderFromDeps(const QJsonArray& deps, const QStringList& interfaceNames, ApiStyle apiStyle, const QString& originName) +{ + const QStringList depNames = dependencyNames(deps); + + QString content; + QTextStream s(&content); + + // Lp (Qt-free) umbrella: no LogosAPI. Each dep wrapper self-creates its + // lp_client on behalf of `originName` (this module), so the struct is + // default-constructible and the glue just does `new LogosModules()`. + if (apiStyle == ApiStyle::Lp) { + s << "#pragma once\n"; + s << "#include \n"; + if (!interfaceNames.isEmpty()) { + s << "#include \n"; + s << "#include \n"; + } + for (const QString& depName : depNames) + s << "#include \"" << depName << "_api.h\"\n"; + for (const QString& ifaceName : interfaceNames) + s << "#include \"" << ifaceName << "_api.h\"\n"; + s << "\n"; + s << "struct LogosModules {\n"; + s << " LogosModules()"; + bool first = true; + for (const QString& depName : depNames) { + s << (first ? " : " : ",\n "); + first = false; + s << depName << "(\"" << originName << "\")"; + } + s << " {}\n"; + for (const QString& depName : depNames) + s << " " << toPascalCase(depName) << " " << depName << ";\n"; + // Interface dependencies: bound at runtime. The bound wrapper is a + // THIN handle over per-provider State the umbrella OWNS for the + // module's lifetime — so a transient `modules().bind_x(p)` temporary + // can register an async callback / event subscription that outlives + // it (the LpClient + RAII subscriptions persist in the map). Keyed by + // provider so repeated binds to the same provider share one client. + for (const QString& ifaceName : interfaceNames) { + const QString className = toPascalCase(ifaceName); + s << " " << className << " bind_" << ifaceName << "(const std::string& moduleName) {\n"; + s << " auto& _st = m_" << ifaceName << "_bound[moduleName];\n"; + s << " if (!_st) _st = std::make_unique<" << className << "::State>(moduleName, \"" << originName << "\");\n"; + s << " return " << className << "(_st.get());\n"; + s << " }\n"; + } + for (const QString& ifaceName : interfaceNames) { + const QString className = toPascalCase(ifaceName); + s << " std::map> m_" + << ifaceName << "_bound;\n"; + } + s << "};\n"; + return content; + } + + // The shape doesn't depend on apiStyle — each dep emits a single + // `_api.h` whose class signature shape was already decided + // at codegen time. The umbrella just `#include`s and aggregates + // each wrapper into the flat `LogosModules` struct. + // + // Only the modules explicitly listed in `metadata.json# + // dependencies` are exposed. Apps that need to manage the core + // (basecamp, logoscore) use liblogos' C API directly rather than + // the typed `LogosModules` aggregate. + // + // Interface dependencies (`metadata.json#interface_dependencies`) are + // NOT fixed members — they bind to a runtime-chosen module — so each + // gets a `bind_(moduleName)` factory instead, returning a bound + // wrapper by value. + s << "#pragma once\n"; + // is only needed for the std::string bind_ overloads; + // omit it when there are no interfaces so the umbrella stays identical + // to its historical form for dependency-only modules. + if (!interfaceNames.isEmpty()) s << "#include \n"; + s << "#include \"logos_api.h\"\n"; + s << "#include \"logos_api_client.h\"\n\n"; + for (const QString& depName : depNames) + s << "#include \"" << depName << "_api.h\"\n"; + for (const QString& ifaceName : interfaceNames) + s << "#include \"" << ifaceName << "_api.h\"\n"; + s << "\n"; + + s << "struct LogosModules {\n"; + s << " explicit LogosModules(LogosAPI* api) : api(api)"; + for (const QString& depName : depNames) + s << ", \n " << depName << "(api)"; + s << " {}\n"; + s << " LogosAPI* api;\n"; + for (const QString& depName : depNames) + s << " " << toPascalCase(depName) << " " << depName << ";\n"; + // Bind factories — one per interface dependency. Two overloads so + // both Qt-typed (QString) and std-typed (std::string) call sites can + // pass the runtime module name without converting at the call site. + for (const QString& ifaceName : interfaceNames) { + const QString className = toPascalCase(ifaceName); + s << " " << className << " bind_" << ifaceName << "(const QString& moduleName) {\n"; + s << " return " << className << "(api, moduleName);\n"; + s << " }\n"; + s << " " << className << " bind_" << ifaceName << "(const std::string& moduleName) {\n"; + s << " return " << className << "(api, QString::fromStdString(moduleName));\n"; + s << " }\n"; + } + s << "};\n"; + return content; +} + +QString makeUmbrellaSourceFromDeps(const QJsonArray& deps, const QStringList& interfaceNames) +{ + // Each dep emits one wrapper `.cpp` (Qt or std — decided at codegen time, + // file name is the same either way), `#include`'d here. Interface wrappers + // (`_api.cpp`) are #include'd the same way. + QString content; + QTextStream s(&content); + s << "#include \"logos_sdk.h\"\n\n"; + for (const QString& depName : dependencyNames(deps)) + s << "#include \"" << depName << "_api.cpp\"\n"; + for (const QString& ifaceName : interfaceNames) + s << "#include \"" << ifaceName << "_api.cpp\"\n"; + s << "\n"; + return content; +} diff --git a/cpp-generator/legacy/generator_lib.h b/cpp-generator/legacy/generator_lib.h index 69e26e8..6e744ad 100644 --- a/cpp-generator/legacy/generator_lib.h +++ b/cpp-generator/legacy/generator_lib.h @@ -102,4 +102,18 @@ QString makeHeaderLp(const QString& moduleName, const QString& className, const QString makeSourceLp(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static, const QJsonArray& records = {}); QVector parseProviderHeader(const QString& headerPath, QTextStream& err); +// The umbrella (`logos_sdk.h` / `logos_sdk.cpp`) over a module's declared +// `metadata.json#dependencies` + interface dependencies: one `#include` and one +// `LogosModules` member per dep, so a module reaches its deps as +// `modules().`. `deps` is the raw metadata array — elements are read +// through dependencyNames() (metadata_dependencies.h), never element by +// element, so includes and members can never disagree about what it declares. +// +// ApiStyle::Lp emits the Qt-free umbrella: no LogosAPI member, each wrapper +// self-creates its lp_client on behalf of `originName` (the module being +// generated for), so the struct is default-constructible. Qt emits the +// LogosAPI-threading form, where `originName` is unused. +QString makeUmbrellaHeaderFromDeps(const QJsonArray& deps, const QStringList& interfaceNames, ApiStyle apiStyle = ApiStyle::Qt, const QString& originName = QString()); +QString makeUmbrellaSourceFromDeps(const QJsonArray& deps, const QStringList& interfaceNames); + #endif // GENERATOR_LIB_H diff --git a/cpp-generator/legacy/main.cpp b/cpp-generator/legacy/main.cpp index 5879864..f82b143 100644 --- a/cpp-generator/legacy/main.cpp +++ b/cpp-generator/legacy/main.cpp @@ -16,6 +16,7 @@ #include #include "logos_provider_interface.h" #include "generator_lib.h" +#include "metadata_dependencies.h" #include "../experimental/lidl_compat.h" #include "../experimental/impl_header_parser.h" #include "../experimental/lidl_emit_common.h" // lidlTypeToQt — the one Qt type mapper @@ -440,134 +441,11 @@ static bool writeUmbrellaHeader(const QString& genDirPath, QTextStream& err) static bool writeUmbrellaHeaderFromDeps(const QString& genDirPath, const QJsonArray& deps, const QStringList& interfaceNames, QTextStream& err, ApiStyle apiStyle = ApiStyle::Qt, const QString& originName = QString()) { - // Lp (Qt-free) umbrella: no LogosAPI. Each dep wrapper self-creates its - // lp_client on behalf of `originName` (this module), so the struct is - // default-constructible and the glue just does `new LogosModules()`. - if (apiStyle == ApiStyle::Lp) { - QDir genDir(genDirPath); - QString content; - QTextStream s(&content); - s << "#pragma once\n"; - s << "#include \n"; - if (!interfaceNames.isEmpty()) { - s << "#include \n"; - s << "#include \n"; - } - for (const QJsonValue& v : deps) { - if (!v.isString()) continue; - s << "#include \"" << v.toString() << "_api.h\"\n"; - } - for (const QString& ifaceName : interfaceNames) - s << "#include \"" << ifaceName << "_api.h\"\n"; - s << "\n"; - s << "struct LogosModules {\n"; - s << " LogosModules()"; - bool first = true; - for (const QJsonValue& v : deps) { - if (!v.isString()) continue; - s << (first ? " : " : ",\n "); - first = false; - s << v.toString() << "(\"" << originName << "\")"; - } - s << " {}\n"; - for (const QJsonValue& v : deps) { - if (!v.isString()) continue; - const QString depName = v.toString(); - s << " " << toPascalCase(depName) << " " << depName << ";\n"; - } - // Interface dependencies: bound at runtime. The bound wrapper is a - // THIN handle over per-provider State the umbrella OWNS for the - // module's lifetime — so a transient `modules().bind_x(p)` temporary - // can register an async callback / event subscription that outlives - // it (the LpClient + RAII subscriptions persist in the map). Keyed by - // provider so repeated binds to the same provider share one client. - for (const QString& ifaceName : interfaceNames) { - const QString className = toPascalCase(ifaceName); - s << " " << className << " bind_" << ifaceName << "(const std::string& moduleName) {\n"; - s << " auto& _st = m_" << ifaceName << "_bound[moduleName];\n"; - s << " if (!_st) _st = std::make_unique<" << className << "::State>(moduleName, \"" << originName << "\");\n"; - s << " return " << className << "(_st.get());\n"; - s << " }\n"; - } - for (const QString& ifaceName : interfaceNames) { - const QString className = toPascalCase(ifaceName); - s << " std::map> m_" - << ifaceName << "_bound;\n"; - } - s << "};\n"; - QFile outFile(genDir.filePath("logos_sdk.h")); - if (!outFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { - err << "Failed to write umbrella header: " << outFile.fileName() << "\n"; - return false; - } - outFile.write(content.toUtf8()); - outFile.close(); - return true; - } - - // Generate logos_sdk.h from metadata.json's dependencies list. The - // shape doesn't depend on apiStyle — each dep emits a single - // `_api.h` whose class signature shape was already decided - // at codegen time. The umbrella just `#include`s and aggregates - // each wrapper into the flat `LogosModules` struct. - // - // Only the modules explicitly listed in `metadata.json# - // dependencies` are exposed. Apps that need to manage the core - // (basecamp, logoscore) use liblogos' C API directly rather than - // the typed `LogosModules` aggregate. - // - // Interface dependencies (`metadata.json#interface_dependencies`) are - // NOT fixed members — they bind to a runtime-chosen module — so each - // gets a `bind_(moduleName)` factory instead, returning a bound - // wrapper by value. + // Emission lives in generator_lib (makeUmbrellaHeaderFromDeps) next to the + // per-module wrapper emitters, so the aggregate can be asserted on without + // a filesystem; this writes what it returns. QDir genDir(genDirPath); - QString content; - QTextStream s(&content); - s << "#pragma once\n"; - // is only needed for the std::string bind_ overloads; - // omit it when there are no interfaces so the umbrella stays identical - // to its historical form for dependency-only modules. - if (!interfaceNames.isEmpty()) s << "#include \n"; - s << "#include \"logos_api.h\"\n"; - s << "#include \"logos_api_client.h\"\n\n"; - for (const QJsonValue& v : deps) { - if (!v.isString()) continue; - QString depName = v.toString(); - s << "#include \"" << depName << "_api.h\"\n"; - } - for (const QString& ifaceName : interfaceNames) { - s << "#include \"" << ifaceName << "_api.h\"\n"; - } - s << "\n"; - - s << "struct LogosModules {\n"; - s << " explicit LogosModules(LogosAPI* api) : api(api)"; - for (const QJsonValue& v : deps) { - if (!v.isString()) continue; - QString depName = v.toString(); - s << ", \n " << depName << "(api)"; - } - s << " {}\n"; - s << " LogosAPI* api;\n"; - for (const QJsonValue& v : deps) { - if (!v.isString()) continue; - QString depName = v.toString(); - QString className = toPascalCase(depName); - s << " " << className << " " << depName << ";\n"; - } - // Bind factories — one per interface dependency. Two overloads so - // both Qt-typed (QString) and std-typed (std::string) call sites can - // pass the runtime module name without converting at the call site. - for (const QString& ifaceName : interfaceNames) { - const QString className = toPascalCase(ifaceName); - s << " " << className << " bind_" << ifaceName << "(const QString& moduleName) {\n"; - s << " return " << className << "(api, moduleName);\n"; - s << " }\n"; - s << " " << className << " bind_" << ifaceName << "(const std::string& moduleName) {\n"; - s << " return " << className << "(api, QString::fromStdString(moduleName));\n"; - s << " }\n"; - } - s << "};\n"; + const QString content = makeUmbrellaHeaderFromDeps(deps, interfaceNames, apiStyle, originName); QFile outFile(genDir.filePath("logos_sdk.h")); if (!outFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { @@ -612,23 +490,10 @@ static bool writeUmbrellaSource(const QString& genDirPath, QTextStream& err) static bool writeUmbrellaSourceFromDeps(const QString& genDirPath, const QJsonArray& deps, const QStringList& interfaceNames, QTextStream& err) { - // Generate logos_sdk.cpp from metadata.json's dependencies list. - // Each dep emits one wrapper `.cpp` (Qt or std — decided at codegen - // time, file name is the same either way), `#include`'d here. - // Interface wrappers (`_api.cpp`) are #include'd the same way. + // Emission lives in generator_lib (makeUmbrellaSourceFromDeps), alongside + // the header's; this writes what it returns. QDir genDir(genDirPath); - QString content; - QTextStream s(&content); - s << "#include \"logos_sdk.h\"\n\n"; - for (const QJsonValue& v : deps) { - if (!v.isString()) continue; - QString depName = v.toString(); - s << "#include \"" << depName << "_api.cpp\"\n"; - } - for (const QString& ifaceName : interfaceNames) { - s << "#include \"" << ifaceName << "_api.cpp\"\n"; - } - s << "\n"; + const QString content = makeUmbrellaSourceFromDeps(deps, interfaceNames); QFile outFile(genDir.filePath("logos_sdk.cpp")); if (!outFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { @@ -1185,9 +1050,7 @@ int legacy_main(int argc, char* argv[]) #endif int overallStatus = 0; - for (const QJsonValue& v : deps) { - if (!v.isString()) continue; - const QString depName = v.toString(); + for (const QString& depName : dependencyNames(deps)) { const QString pluginFileName = depName + "_plugin" + suffix; const QString pluginPath = moduleDir.filePath(pluginFileName); if (!QFileInfo::exists(pluginPath)) { @@ -1213,10 +1076,8 @@ int legacy_main(int argc, char* argv[]) } return overallStatus; } else { - for (const QJsonValue& v : deps) { - if (v.isString()) { - out << v.toString() << "\n"; - } + for (const QString& depName : dependencyNames(deps)) { + out << depName << "\n"; } out.flush(); return 0; diff --git a/cpp-generator/metadata_dependencies.h b/cpp-generator/metadata_dependencies.h new file mode 100644 index 0000000..2b79984 --- /dev/null +++ b/cpp-generator/metadata_dependencies.h @@ -0,0 +1,45 @@ +#ifndef METADATA_DEPENDENCIES_H +#define METADATA_DEPENDENCIES_H + +#include +#include +#include +#include +#include + +/// The module named by one `metadata.json` `dependencies[]` element. +/// +/// An element is either a bare name or an object holding that name alongside +/// the constraints an installer resolves it by (version range, signer DID); +/// generation needs the name only. Empty for an element that names nothing. +inline QString dependencyName(const QJsonValue& entry) +{ + if (entry.isString()) { + return entry.toString(); + } + if (entry.isObject()) { + return entry.toObject().value("name").toString(); + } + return QString(); +} + +/// Every module named by a `metadata.json` `dependencies[]` array, in order. +/// +/// Read the array through this rather than iterating it: an emitter that walks +/// `deps` itself decides on its own what an element names, and one that decides +/// differently from its neighbours emits an aggregate whose members and includes +/// disagree — which does not fail until the generated code is compiled. +/// Elements that name nothing are dropped. +inline QStringList dependencyNames(const QJsonArray& entries) +{ + QStringList names; + for (const QJsonValue& entry : entries) { + const QString name = dependencyName(entry); + if (!name.isEmpty()) { + names.append(name); + } + } + return names; +} + +#endif // METADATA_DEPENDENCIES_H diff --git a/nix/bin.nix b/nix/bin.nix index 526700a..8397422 100644 --- a/nix/bin.nix +++ b/nix/bin.nix @@ -46,6 +46,7 @@ pkgs.stdenv.mkDerivation { cp cpp-generator/experimental/lidl_compat.h \ cpp-generator/experimental/impl_header_parser.h cpp-generator/experimental/impl_header_parser.cpp \ cpp-generator/experimental/lidl_emit_common.h cpp-generator/experimental/lidl_emit_common.cpp \ + cpp-generator/metadata_dependencies.h \ $out/share/lidl-frontend/ runHook postInstall diff --git a/tests/experimental/fixtures/object_deps_metadata.json b/tests/experimental/fixtures/object_deps_metadata.json new file mode 100644 index 0000000..b151b18 --- /dev/null +++ b/tests/experimental/fixtures/object_deps_metadata.json @@ -0,0 +1,15 @@ +{ + "name": "sample_module", + "version": "1.2.3", + "description": "A sample module whose dependencies carry resolution constraints", + "author": "Test", + "type": "core", + "category": "testing", + "main": "sample_module_plugin", + "dependencies": [ + "dep_a", + { "name": "dep_b", "version": "=1.2.3" }, + { "name": "dep_c", "version": "^2.0", "signer": "did:jwk:abc" }, + {} + ] +} diff --git a/tests/experimental/test_impl_header_parser.cpp b/tests/experimental/test_impl_header_parser.cpp index bdfca02..d405afc 100644 --- a/tests/experimental/test_impl_header_parser.cpp +++ b/tests/experimental/test_impl_header_parser.cpp @@ -64,6 +64,25 @@ TEST_F(ImplHeaderParserTest, ParsesSampleImpl) EXPECT_GE(r.module.methods.size(), 10); } +// A dependency entry may carry the constraints an installer resolves it by, +// and generation still needs the name. Read as a plain string, an object entry +// came back empty and the module it names vanished from the generated +// LogosModules aggregate, so every call through it failed to compile. +TEST_F(ImplHeaderParserTest, ReadsDependenciesDeclaredInObjectForm) +{ + auto r = parseImplHeader( + fixturesDir() + "/sample_impl.h", + "SampleModuleImpl", + fixturesDir() + "/object_deps_metadata.json", + err); + ASSERT_FALSE(r.hasError()) << r.error.toStdString(); + + ASSERT_EQ(r.module.depends.size(), 3); + EXPECT_EQ(r.module.depends[0], "dep_a"); + EXPECT_EQ(r.module.depends[1], "dep_b"); + EXPECT_EQ(r.module.depends[2], "dep_c"); +} + TEST_F(ImplHeaderParserTest, MethodTypes) { auto r = parseImplHeader( diff --git a/tests/generator/CMakeLists.txt b/tests/generator/CMakeLists.txt index 0075af2..b7a8a65 100644 --- a/tests/generator/CMakeLists.txt +++ b/tests/generator/CMakeLists.txt @@ -7,6 +7,7 @@ add_executable(generator_tests test_to_qvariant_conversion.cpp test_make_header.cpp test_make_source.cpp + test_make_umbrella.cpp test_parse_provider_header.cpp test_records.cpp ) diff --git a/tests/generator/test_make_umbrella.cpp b/tests/generator/test_make_umbrella.cpp new file mode 100644 index 0000000..258a4ac --- /dev/null +++ b/tests/generator/test_make_umbrella.cpp @@ -0,0 +1,129 @@ +#include +#include +#include +#include +#include "generator_lib.h" + +// The umbrella aggregates a module's declared `metadata.json#dependencies` into +// `LogosModules`. A dependency entry is either a bare name or an object holding +// that name alongside the constraints an installer resolves it by, and the two +// forms have to generate identical code — the constraints are the installer's +// business, not the generator's. +// +// What makes this worth asserting on rather than trusting: the aggregate is +// emitted by several passes over the same array (includes, constructor +// initialisers, members), so a form only one pass understands yields a member +// whose type was never included — an aggregate that no longer compiles, and one +// that nothing catches until a module builds against it. + +namespace { + +QJsonArray depsMixedForms() +{ + QJsonObject withVersion; + withVersion["name"] = "dep_b"; + withVersion["version"] = "=1.2.3"; + + QJsonObject withSigner; + withSigner["name"] = "dep_c"; + withSigner["version"] = "^2.0"; + withSigner["signer"] = "did:jwk:abc"; + + QJsonArray deps; + deps.append("dep_a"); + deps.append(withVersion); + deps.append(withSigner); + return deps; +} + +} // namespace + +// Lp is the umbrella every `interface: universal` core module and every cdylib +// module generates (logos-plugin-qt picks --api-style lp for both). +TEST(MakeUmbrellaTest, LpAggregatesDependenciesDeclaredInEitherForm) +{ + const QString h = makeUmbrellaHeaderFromDeps(depsMixedForms(), {}, ApiStyle::Lp, "sample_module"); + + EXPECT_TRUE(h.contains("#include \"dep_a_api.h\"")) << h.toStdString(); + EXPECT_TRUE(h.contains("#include \"dep_b_api.h\"")) << h.toStdString(); + EXPECT_TRUE(h.contains("#include \"dep_c_api.h\"")) << h.toStdString(); + + EXPECT_TRUE(h.contains("DepA dep_a;")) << h.toStdString(); + EXPECT_TRUE(h.contains("DepB dep_b;")) << h.toStdString(); + EXPECT_TRUE(h.contains("DepC dep_c;")) << h.toStdString(); + + // Lp wrappers self-create their lp_client on behalf of this module. + EXPECT_TRUE(h.contains("dep_b(\"sample_module\")")) << h.toStdString(); + EXPECT_TRUE(h.contains("dep_c(\"sample_module\")")) << h.toStdString(); +} + +TEST(MakeUmbrellaTest, QtAggregatesDependenciesDeclaredInEitherForm) +{ + const QString h = makeUmbrellaHeaderFromDeps(depsMixedForms(), {}, ApiStyle::Qt); + + EXPECT_TRUE(h.contains("#include \"dep_a_api.h\"")) << h.toStdString(); + EXPECT_TRUE(h.contains("#include \"dep_b_api.h\"")) << h.toStdString(); + EXPECT_TRUE(h.contains("#include \"dep_c_api.h\"")) << h.toStdString(); + + EXPECT_TRUE(h.contains("DepA dep_a;")) << h.toStdString(); + EXPECT_TRUE(h.contains("DepB dep_b;")) << h.toStdString(); + EXPECT_TRUE(h.contains("DepC dep_c;")) << h.toStdString(); + + EXPECT_TRUE(h.contains("dep_b(api)")) << h.toStdString(); + EXPECT_TRUE(h.contains("dep_c(api)")) << h.toStdString(); +} + +// Every dep a member declaration mentions must have been included, in both +// flavors — the pairing is the invariant, independent of which form declared it. +TEST(MakeUmbrellaTest, EveryMemberTypeIsIncluded) +{ + for (ApiStyle style : {ApiStyle::Lp, ApiStyle::Qt}) { + const QString h = makeUmbrellaHeaderFromDeps(depsMixedForms(), {}, style, "sample_module"); + for (const QString& dep : {QStringLiteral("dep_a"), QStringLiteral("dep_b"), QStringLiteral("dep_c")}) { + const bool included = h.contains("#include \"" + dep + "_api.h\""); + const bool member = h.contains(toPascalCase(dep) + " " + dep + ";"); + EXPECT_EQ(included, member) + << "'" << dep.toStdString() << "' is a member without an include (or vice versa):\n" + << h.toStdString(); + } + } +} + +TEST(MakeUmbrellaTest, SourceIncludesEveryDependencyWrapper) +{ + const QString c = makeUmbrellaSourceFromDeps(depsMixedForms(), {"some_iface"}); + + EXPECT_TRUE(c.contains("#include \"dep_a_api.cpp\"")) << c.toStdString(); + EXPECT_TRUE(c.contains("#include \"dep_b_api.cpp\"")) << c.toStdString(); + EXPECT_TRUE(c.contains("#include \"dep_c_api.cpp\"")) << c.toStdString(); + EXPECT_TRUE(c.contains("#include \"some_iface_api.cpp\"")) << c.toStdString(); +} + +// An entry that names nothing is dropped rather than emitted as an empty +// member, and drops out of the aggregate entirely. +TEST(MakeUmbrellaTest, EntryNamingNothingIsDropped) +{ + QJsonArray deps; + deps.append("dep_a"); + deps.append(QJsonObject{}); + deps.append(QJsonObject{{"version", "=1.0.0"}}); + + for (ApiStyle style : {ApiStyle::Lp, ApiStyle::Qt}) { + const QString h = makeUmbrellaHeaderFromDeps(deps, {}, style, "sample_module"); + EXPECT_TRUE(h.contains("DepA dep_a;")) << h.toStdString(); + EXPECT_FALSE(h.contains("#include \"_api.h\"")) << h.toStdString(); + EXPECT_FALSE(h.contains("Module ;")) << h.toStdString(); + } +} + +// A module with no dependencies still gets a compilable, empty aggregate. +TEST(MakeUmbrellaTest, NoDependenciesStillEmitsTheAggregate) +{ + const QString lp = makeUmbrellaHeaderFromDeps({}, {}, ApiStyle::Lp, "sample_module"); + EXPECT_TRUE(lp.contains("struct LogosModules {")) << lp.toStdString(); + EXPECT_TRUE(lp.contains("LogosModules() {}")) << lp.toStdString(); + + const QString qt = makeUmbrellaHeaderFromDeps({}, {}, ApiStyle::Qt); + EXPECT_TRUE(qt.contains("struct LogosModules {")) << qt.toStdString(); + EXPECT_TRUE(qt.contains("LogosAPI* api;")) << qt.toStdString(); +}