Files
logos-cpp-sdk/tests/experimental/test_impl_header_parser.cpp
T
Dario LipicarandClaude Opus 5 44b92b0480 feat(generator): no silent admissions — an unknown C++ spelling is a build error (salvage of #112) (#127)
* feat(parser): name nlohmann::json explicitly, ahead of killing the fallback

`nlohmann::json` (and the `json` alias) has never had a branch in
cppTypeToLidl. It reaches the opaque `any` the same way every unrecognised
spelling does: the fallback at the bottom of the function.

That is fine while the fallback is silent and wrong the moment it becomes an
error, because `any` is the RIGHT answer here. test_fullapi_cpp declares
`nlohmann::json echoAny(const nlohmann::json&)`, `bool fireAnyEvent(const
nlohmann::json&)` and `logos_events: void anyEvent(const nlohmann::json&)`,
and those three are the cross-language conformance chain's `any` cells — they
must keep publishing `any`.

So this lands first and on its own. It maps to the bare `any` primitive,
which is exactly what the fallback already produced, making the change
output-neutral: generating over every impl header and every .lidl in the
workspace produces 764 byte-identical artifacts. That is what lets the later
commit treat everything still reaching the fallback as a silent admission
rather than a legitimate `any`.

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

* feat(cdylib): a typed map decodes into the author's own container

`{tstr: T}` has two C++ spellings — std::map<std::string, T> and
std::unordered_map<std::string, T> — and logos_codec.h specializes Codec for
both, because they are the same wire shape. The generated dispatch named one:

    lidlImpl().echoIntMap(logos::fromJson<std::map<std::string, int64_t>>(...))

fromJson returns a std::map, and a std::map does not convert to an
unordered_map parameter. So the container the author picked decided whether
generated code they never wrote compiles — with the diagnostic pointing at
that generated line, not at their declaration.

logos::JsonArg (logos-protocol, already on master) exists for exactly this:
it instantiates its conversion operator with the parameter's own type, so the
author's declaration drives the decode. The return side is the same problem
mirrored, and `logos::toJson(result)` deduces instead of asserting.

Restricted to Map because every other LIDL type has one C++ spelling here,
and because JsonArg documents one target it cannot serve — std::optional<X>,
whose converting constructor out-ranks the proxy's conversion operator. The
Optional branch returns before reaching this code.

For a std::map author nothing changes: JsonArg instantiates the same
Codec<std::map<...>>::from at the same path, and toJson deduces the same T.
Compile-checked against both spellings, including a bad element still failing
with `expected string at arg0.k, got number`. Across the whole workspace the
emitted delta is 5 methods, all in test_fullapi_ext.

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

* feat(parser): an unknown C++ spelling is a build error, not a silent `any`

cppTypeToLidl ended with `// Fallback: treat as opaque` -> `any`, and `any`
is ADMITTED by every backend gate. So a spelling nobody had written a branch
for was accepted in silence, published as `any`, and dispatched as a bare
`lidlImpl().f(args.at(0))` — no logos::fromJson<>, no check. That is the one
hole #113-#122 closed for every typed slot and left open for anything that
reached `any`.

An 11-method hostile probe was admitted wholesale: uint32_t, size_t, float
and uint8_t all typed as `any`; std::set, std::vector<std::pair<...>> and a
non-string-keyed map as `any`; std::vector<uint32_t> as `[any]`.

Now every spelling with no LIDL type is collected with the declaration that
carries it, and parseImplHeader turns the list into a parse error naming the
offending type and the fix:

  method 'send_generic_public_transaction': parameter 'instruction' declared
  `const std::vector<uint32_t>&`, whose element `uint32_t` has no LIDL type.
    LIDL numbers are 64-bit only. Declare it `uint64_t` (LIDL `uint`).
    Widening is source-compatible for every caller; a narrow type on the
    wire is not, which is why LIDL has none.

Numbers get that tailored hint (uint8_t its own — it means bytes here, and
only as std::vector<uint8_t>); sets, pairs/tuples, non-string map keys,
list/deque/array, Qt types and pointers each get theirs; anything else gets
the full table of recognised spellings. A hint that does not name a
replacement just moves the guesswork, so all of them do.

std::unordered_map<std::string, T> joins std::map as a spelling of
`{tstr: T}` — the codec has always handled both, and the previous commit made
the generated dispatch bind whichever the author declared. Two slots are the
exception and say so: a record FIELD and an event PARAMETER, where the
generator writes the spelling out into code the author's own declaration has
to match and can only pick one name.

Three properties keep this from breaking things it should not:

  * The MAPPING is unchanged. cppTypeToLidl still returns `any` for an
    unsupported spelling; only a diagnostic is recorded. A diagnostic that is
    later withdrawn therefore leaves output byte-identical.
  * Diagnostics are withdrawn for declarations that never reach the contract
    — a helper struct dropped by keepOnlyReferencedRecords, a reserved
    lifecycle hook (onContextReady and friends), a struct with no parsed
    fields. Publishing is what makes a type a promise.
  * An empty spelling is not a C++ type, it is this line-based parser failing
    to find one. It keeps the old behaviour rather than reporting `''`.

Also fixes a latent ordering bug found while threading the context through:
metadata.json's event parameters were typed BEFORE the header was read, i.e.
against whatever g_recordNames the previous module's parse had left behind.
They are now read there and typed after scanForRecords, in the same position
in module.events as before.

Verified by generating over every impl header and every .lidl in the
workspace: 31 derived contracts byte-identical, 368 consumer-umbrella files
byte-identical under both --api-style qt and --api-style lp, 46 generated
types headers byte-identical. Exactly two modules now fail, at exactly the
four slots a prior scan identified as silent admissions.

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

* feat(cdylib): a wrong argument count reports invalid_args

The arity gate was `if (args.size() < N) return nullptr;`. The Qt glue turns a
NULL reply into an empty QVariant, so "you passed 2 of 4 arguments" was
indistinguishable from a method that legitimately returned nothing.

logos-rust-sdk already ships the other half. src/args.rs::invalid_args is
documented "Same code and message as the C++ generated glue" and pinned by a
test named invalid_args_shape_matches_cpp — both of which were false: Rust
answered a structured object and C++ answered NULL. Checked against the JSON
that crate actually emits rather than against its comment, the two are now
byte-identical:

    {"code":"invalid_args","message":"expected 4 arguments, got 2","origin":"my_module"}

`expected` counts REQUIRED parameters in both, so a trailing optional does not
change it.

The guard is emitted only when the method has at least one required
parameter. args.size() is unsigned, so `< 0` never fires: a zero-argument
method carried a dead branch. The Rust generator skips it for the same
reason, so the two now agree on when a check exists at all.

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

* refactor(cdylib): delete the dead emitted base64 codec

#117 replaced the codec the generator emitted into every module with
logos-protocol's logos_codec.h, but left the base64 pair it had grown around:

  * lidlB64Idx + lidlBytesFromJson — 55 emitted lines with NO call site at
    all. Every byte parameter had already moved to
    logos::bytesFromJsonLenient. Confirmed across the whole workspace: in 48
    generated export TUs, all 48 mentions of lidlBytesFromJson are its own
    definition line.
  * lidlB64UrlEncode + lidlBytesToJson — 34 emitted lines that are
    logos::bytesToJson rewritten, in a translation unit that already includes
    it through "<module>_types.h".

Scalar bstr slots now call logos::bytesToJson. Composite ones ([bstr],
{tstr: bstr}, records) have gone through logos::Codec since #117, so this
removes the last place a module carried its own copy of an encoder — the
arrangement that once let the emitted and canonical halves disagree about
padded base64, and that #117's own comment set out to end.

hasBytesEventParam goes with it: it existed only to keep the emitted copy
from sitting unused in the events sidecar of modules whose events carry no
binary data, and the `namespace { }` block it gated is gone too.

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

* docs(doctest): the bstr event marshal is logos::bytesToJson now

Commit (D) deleted the dead emitted base64 codec, so the cdylib events
sidecar calls logos::bytesToJson -- the one in logos_codec.h, included in the
same translation unit -- instead of emitting its own lidlBytesToJson. The
doctest still pinned the old spelling and failed on the new output:

  expected 'args.push_back(lidlBytesToJson(frame));' not found in output

The prose around it is unchanged and still correct: the payload is still the
canonical {"_bytes": "<base64url>"} form, which is the property that
assertion exists to guard (#99). Only the symbol moved.

Verified against real generator output rather than by search-and-replace:
built the branch generator, ran --backend cdylib over a sensor_module contract
with a bstr event param, and read the emitted line:

  args.push_back(logos::bytesToJson(frame));

Checked the rest of the specs for other stale symbols (lidlStrdup, b64Url,
hasBytesEventParam, the old 'return nullptr' arity gate) -- this was the only
one.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:19:04 -03:00

933 lines
36 KiB
C++

#include <gtest/gtest.h>
#include <QTemporaryDir>
#include <algorithm>
#include "impl_header_parser.h"
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QTextStream>
// Helper: find the fixtures directory.
// 1. FIXTURES_DIR env var — set by CI to point to installed fixtures
// 2. FIXTURES_DIR compile define — set by CMake, works during ctest in nix sandbox
// 3. ../fixtures relative to binary — nix install layout ($out/bin/ + $out/fixtures/)
static QString fixturesDir()
{
// Environment variable takes priority (set by CI or user)
QByteArray envDir = qgetenv("FIXTURES_DIR");
if (!envDir.isEmpty() && QDir(envDir).exists())
return QString::fromUtf8(envDir);
#ifdef FIXTURES_DIR
if (QDir(FIXTURES_DIR).exists())
return QString(FIXTURES_DIR);
#endif
// Installed layout: $out/bin/experimental_tests + $out/fixtures/
QString binDir = QCoreApplication::applicationDirPath();
if (!binDir.isEmpty()) {
QString installed = QDir::cleanPath(binDir + "/../fixtures");
if (QDir(installed).exists())
return installed;
}
return QDir::currentPath() + "/fixtures";
}
class ImplHeaderParserTest : public ::testing::Test {
protected:
QString errOutput;
QTextStream err{&errOutput};
};
// ---------------------------------------------------------------------------
// Basic parsing
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, ParsesSampleImpl)
{
auto r = parseImplHeader(
fixturesDir() + "/sample_impl.h",
"SampleModuleImpl",
fixturesDir() + "/sample_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
// Metadata from JSON
EXPECT_EQ(r.module.name, "sample_module");
EXPECT_EQ(r.module.version, "1.2.3");
EXPECT_EQ(r.module.description, "A sample module for testing");
EXPECT_EQ(r.module.category, "testing");
ASSERT_EQ(r.module.depends.size(), 2);
EXPECT_EQ(r.module.depends[0], "dep_a");
// Methods — should find all public methods, skip ctor/dtor/private
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(
fixturesDir() + "/sample_impl.h",
"SampleModuleImpl",
fixturesDir() + "/sample_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
// Find specific methods and check their types
auto findMethod = [&](const std::string& name) -> const MethodDecl* {
for (const auto& m : r.module.methods)
if (m.name == name) return &m;
return nullptr;
};
// std::string greet(const std::string& name) → tstr
auto greet = findMethod("greet");
ASSERT_NE(greet, nullptr);
EXPECT_EQ(greet->returnType.name, "tstr");
ASSERT_EQ(greet->params.size(), 1);
EXPECT_EQ(greet->params[0].name, "name");
EXPECT_EQ(greet->params[0].type.name, "tstr");
// bool isValid(const std::string& input) → bool
auto isValid = findMethod("isValid");
ASSERT_NE(isValid, nullptr);
EXPECT_EQ(isValid->returnType.name, "bool");
// int64_t getCount() → int
auto getCount = findMethod("getCount");
ASSERT_NE(getCount, nullptr);
EXPECT_EQ(getCount->returnType.name, "int");
EXPECT_TRUE(getCount->params.empty());
// uint64_t getSize() → uint
auto getSize = findMethod("getSize");
ASSERT_NE(getSize, nullptr);
EXPECT_EQ(getSize->returnType.name, "uint");
// double getScore() → float64
auto getScore = findMethod("getScore");
ASSERT_NE(getScore, nullptr);
EXPECT_EQ(getScore->returnType.name, "float64");
// void doNothing() → void
auto doNothing = findMethod("doNothing");
ASSERT_NE(doNothing, nullptr);
EXPECT_EQ(doNothing->returnType.name, "void");
// std::vector<std::string> getNames() → [tstr]
auto getNames = findMethod("getNames");
ASSERT_NE(getNames, nullptr);
EXPECT_EQ(getNames->returnType.kind, TypeExpr::Array);
EXPECT_EQ(getNames->returnType.elements[0].name, "tstr");
// std::vector<uint8_t> getData() → bstr
auto getData = findMethod("getData");
ASSERT_NE(getData, nullptr);
EXPECT_EQ(getData->returnType.name, "bstr");
// std::vector<int64_t> getIds() → [int]
auto getIds = findMethod("getIds");
ASSERT_NE(getIds, nullptr);
EXPECT_EQ(getIds->returnType.kind, TypeExpr::Array);
EXPECT_EQ(getIds->returnType.elements[0].name, "int");
// std::string combine(const std::string& a, const std::string& b, int64_t count)
auto combine = findMethod("combine");
ASSERT_NE(combine, nullptr);
EXPECT_EQ(combine->returnType.name, "tstr");
ASSERT_EQ(combine->params.size(), 3);
EXPECT_EQ(combine->params[0].name, "a");
EXPECT_EQ(combine->params[1].name, "b");
EXPECT_EQ(combine->params[2].name, "count");
EXPECT_EQ(combine->params[2].type.name, "int");
}
TEST_F(ImplHeaderParserTest, SkipsPrivateMethods)
{
auto r = parseImplHeader(
fixturesDir() + "/sample_impl.h",
"SampleModuleImpl",
fixturesDir() + "/sample_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
for (const auto& m : r.module.methods) {
EXPECT_NE(m.name, "internalHelper") << "Private method should not be parsed";
}
}
// ---------------------------------------------------------------------------
// Empty class
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, EmptyClass)
{
auto r = parseImplHeader(
fixturesDir() + "/empty_class_impl.h",
"EmptyClassImpl",
fixturesDir() + "/empty_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
EXPECT_TRUE(r.module.methods.empty());
// Should have a warning in err output
EXPECT_TRUE(errOutput.contains("Warning"));
}
// ---------------------------------------------------------------------------
// Complex class with access specifier changes
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, ComplexAccessSpecifiers)
{
auto r = parseImplHeader(
fixturesDir() + "/complex_impl.h",
"ComplexModuleImpl",
fixturesDir() + "/empty_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
auto findMethod = [&](const std::string& name) -> const MethodDecl* {
for (const auto& m : r.module.methods)
if (m.name == name) return &m;
return nullptr;
};
// First public section
EXPECT_NE(findMethod("firstMethod"), nullptr);
// Second public section (after protected)
EXPECT_NE(findMethod("secondMethod"), nullptr);
EXPECT_NE(findMethod("thirdMethod"), nullptr);
// Protected method should be skipped
EXPECT_EQ(findMethod("protectedHelper"), nullptr);
// Private method should be skipped
EXPECT_EQ(findMethod("privateHelper"), nullptr);
}
// ---------------------------------------------------------------------------
// Error cases
// ---------------------------------------------------------------------------
// A struct in an impl header becomes a contract `type` — but ONLY if the API
// mentions it. A header routinely declares private helpers, and publishing
// those would change the module's interface as a side effect of an internal
// refactor. Verified against two real modules: openmetrics' `ModuleSource` and
// the package manager's in-class `PendingAction` were both being published.
TEST_F(ImplHeaderParserTest, OnlyApiReferencedStructsBecomeRecords)
{
auto r = parseImplHeader(
fixturesDir() + "/records_impl.h",
"RecordsImpl",
fixturesDir() + "/records_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
std::vector<std::string> names;
for (const auto& t : r.module.types) names.push_back(t.name);
std::sort(names.begin(), names.end());
// Blob is named directly; Wrapper too. Internal and Helper are not.
ASSERT_EQ(names.size(), 2u) << "published: " << [&]{
std::string j; for (const auto& n : names) j += n + " "; return j; }();
EXPECT_EQ(names[0], "Blob");
EXPECT_EQ(names[1], "Wrapper");
// A field with a trailing comment must NOT be silently dropped: a record
// published with a partial field list looks like a contract and is not one.
for (const auto& t : r.module.types) {
if (t.name != "Blob") continue;
ASSERT_EQ(t.fields.size(), 3u);
EXPECT_EQ(t.fields[2].name, "payload");
EXPECT_EQ(t.fields[2].type.name, "bstr");
}
}
// The closure is transitive: a record reaches the contract because something
// the API names refers to it, however indirectly.
TEST_F(ImplHeaderParserTest, RecordsReachableOnlyThroughAnotherRecordAreKept)
{
auto r = parseImplHeader(
fixturesDir() + "/records_impl.h",
"RecordsImpl",
fixturesDir() + "/records_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
// Wrapper's own fields name Blob; both survive even though a signature
// could have named only one of them.
bool sawWrapper = false;
for (const auto& t : r.module.types) {
if (t.name != "Wrapper") continue;
sawWrapper = true;
ASSERT_EQ(t.fields.size(), 2u);
EXPECT_EQ(t.fields[0].type.name, "Blob");
EXPECT_EQ(t.fields[1].type.elements.at(0).name, "Blob");
}
EXPECT_TRUE(sawWrapper);
}
TEST_F(ImplHeaderParserTest, MissingHeaderFile)
{
auto r = parseImplHeader(
"/nonexistent/path.h",
"Foo",
fixturesDir() + "/sample_metadata.json",
err);
EXPECT_TRUE(r.hasError());
EXPECT_TRUE(r.error.contains("Failed to open header"));
}
TEST_F(ImplHeaderParserTest, MissingMetadataFile)
{
auto r = parseImplHeader(
fixturesDir() + "/sample_impl.h",
"SampleModuleImpl",
"/nonexistent/metadata.json",
err);
EXPECT_TRUE(r.hasError());
EXPECT_TRUE(r.error.contains("Failed to open metadata"));
}
TEST_F(ImplHeaderParserTest, WrongClassName)
{
auto r = parseImplHeader(
fixturesDir() + "/sample_impl.h",
"NonExistentClass",
fixturesDir() + "/sample_metadata.json",
err);
// Not an error per se, but should find zero methods and warn
ASSERT_FALSE(r.hasError());
EXPECT_TRUE(r.module.methods.empty());
EXPECT_TRUE(errOutput.contains("Warning"));
}
// ---------------------------------------------------------------------------
// LogosMap / LogosList, Qt collections, metadata events, emitEvent detection
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, UniversalTypesAndMetadataEvents)
{
auto r = parseImplHeader(
fixturesDir() + "/universal_impl.h",
"UniversalImpl",
fixturesDir() + "/universal_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
EXPECT_EQ(r.module.name, "universal_mod");
EXPECT_EQ(r.module.version, "2.0.0");
ASSERT_EQ(r.module.events.size(), 1);
EXPECT_EQ(r.module.events[0].name, "onReady");
// Optional per-event description carried from metadata.json events[].
EXPECT_EQ(r.module.events[0].description, "Fired once the module is ready.");
ASSERT_EQ(r.module.events[0].params.size(), 1);
EXPECT_EQ(r.module.events[0].params[0].name, "info");
EXPECT_EQ(r.module.events[0].params[0].type.name, "tstr");
auto findMethod = [&](const std::string& name) -> const MethodDecl* {
for (const auto& m : r.module.methods)
if (m.name == name) return &m;
return nullptr;
};
// The fixture declares a `std::function<…> emitEvent` member. The old
// legacy hook treated it specially; now such members are simply skipped
// and never mistaken for a callable method.
EXPECT_EQ(findMethod("emitEvent"), nullptr);
auto fetchMap = findMethod("fetchMap");
ASSERT_NE(fetchMap, nullptr);
EXPECT_EQ(fetchMap->returnType.kind, TypeExpr::Map);
EXPECT_TRUE(fetchMap->jsonReturn);
auto fetchList = findMethod("fetchList");
ASSERT_NE(fetchList, nullptr);
EXPECT_EQ(fetchList->returnType.kind, TypeExpr::Array);
EXPECT_EQ(fetchList->returnType.elements[0].name, "any");
EXPECT_TRUE(fetchList->jsonReturn);
auto asVariantMap = findMethod("asVariantMap");
ASSERT_NE(asVariantMap, nullptr);
EXPECT_EQ(asVariantMap->returnType.kind, TypeExpr::Map);
EXPECT_FALSE(asVariantMap->jsonReturn);
auto listNames = findMethod("listNames");
ASSERT_NE(listNames, nullptr);
EXPECT_EQ(listNames->returnType.kind, TypeExpr::Array);
EXPECT_EQ(listNames->returnType.elements[0].name, "tstr");
EXPECT_FALSE(listNames->jsonReturn);
auto anyList = findMethod("anyList");
ASSERT_NE(anyList, nullptr);
EXPECT_EQ(anyList->returnType.kind, TypeExpr::Array);
EXPECT_EQ(anyList->returnType.elements[0].name, "any");
EXPECT_FALSE(anyList->jsonReturn);
auto fetchResult = findMethod("fetchResult");
ASSERT_NE(fetchResult, nullptr);
EXPECT_EQ(fetchResult->returnType.kind, TypeExpr::Primitive);
EXPECT_EQ(fetchResult->returnType.name, "result");
EXPECT_FALSE(fetchResult->jsonReturn);
EXPECT_TRUE(fetchResult->resultReturn);
auto fetchResultNodiscard = findMethod("fetchResultNodiscard");
ASSERT_NE(fetchResultNodiscard, nullptr);
EXPECT_EQ(fetchResultNodiscard->returnType.name, "result");
EXPECT_TRUE(fetchResultNodiscard->resultReturn);
auto fetchResultStatic = findMethod("fetchResultStatic");
ASSERT_NE(fetchResultStatic, nullptr);
EXPECT_EQ(fetchResultStatic->returnType.name, "result");
EXPECT_TRUE(fetchResultStatic->resultReturn);
auto fetchResultNodiscardStatic = findMethod("fetchResultNodiscardStatic");
ASSERT_NE(fetchResultNodiscardStatic, nullptr);
EXPECT_EQ(fetchResultNodiscardStatic->returnType.name, "result");
EXPECT_TRUE(fetchResultNodiscardStatic->resultReturn);
auto fetchResultStaticNodiscard = findMethod("fetchResultStaticNodiscard");
ASSERT_NE(fetchResultStaticNodiscard, nullptr);
EXPECT_EQ(fetchResultStaticNodiscard->returnType.name, "result");
EXPECT_TRUE(fetchResultStaticNodiscard->resultReturn);
auto fetchResultMultiAttr = findMethod("fetchResultMultiAttr");
ASSERT_NE(fetchResultMultiAttr, nullptr);
EXPECT_EQ(fetchResultMultiAttr->returnType.name, "result");
EXPECT_TRUE(fetchResultMultiAttr->resultReturn);
auto fetchResultInlineStatic = findMethod("fetchResultInlineStatic");
ASSERT_NE(fetchResultInlineStatic, nullptr);
EXPECT_EQ(fetchResultInlineStatic->returnType.name, "result");
EXPECT_TRUE(fetchResultInlineStatic->resultReturn);
auto fetchResultConsteval = findMethod("fetchResultConsteval");
ASSERT_NE(fetchResultConsteval, nullptr);
EXPECT_EQ(fetchResultConsteval->returnType.name, "result");
EXPECT_TRUE(fetchResultConsteval->resultReturn);
for (const auto& m : r.module.methods) {
EXPECT_NE(m.name, "void") << "Keyword should not appear as method name";
}
}
// ---------------------------------------------------------------------------
// Event doc comments: `///` above a `logos_events:` declaration becomes the
// event's description (same capture rules as methods: doc-comments only,
// adjacent-only, multi-line joined with \n).
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, EventDocCommentsFromHeader)
{
auto r = parseImplHeader(
fixturesDir() + "/documented_events_impl.h",
"DocumentedEventsImpl",
fixturesDir() + "/documented_events_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
ASSERT_EQ(r.module.events.size(), 3);
// Multi-line `///` doc comment: the two lines are joined with a newline.
EXPECT_EQ(r.module.events[0].name, "userLoggedIn");
EXPECT_EQ(r.module.events[0].description,
"Fired once the user has authenticated.\n"
"Carries the freshly issued session token.");
ASSERT_EQ(r.module.events[0].params.size(), 2);
EXPECT_EQ(r.module.events[0].params[0].name, "userId");
EXPECT_EQ(r.module.events[0].params[1].name, "token");
// A plain `//` comment is not a doc comment → no description captured.
EXPECT_EQ(r.module.events[1].name, "heartbeat");
EXPECT_TRUE(r.module.events[1].description.empty());
// Single-line `///` doc comment.
EXPECT_EQ(r.module.events[2].name, "shutdown");
EXPECT_EQ(r.module.events[2].description, "Single-line documented event.");
}
// ---------------------------------------------------------------------------
// Issue #76: a section specifier and a declaration on the *same* physical
// line (`logos_events : void foo();`, as clang-format / prettier produce)
// must be parsed identically to the newline-separated form. The code after
// the colon must not be discarded.
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, SameLineSectionSpecifiers)
{
auto r = parseImplHeader(
fixturesDir() + "/same_line_events_impl.h",
"SameLineEventsImpl",
fixturesDir() + "/same_line_events_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
auto findEvent = [&](const std::string& name) -> const EventDecl* {
for (const auto& e : r.module.events)
if (e.name == name) return &e;
return nullptr;
};
auto findMethod = [&](const std::string& name) -> const MethodDecl* {
for (const auto& m : r.module.methods)
if (m.name == name) return &m;
return nullptr;
};
// The exact prettier form from the issue:
// logos_events : void versionReady(const std::string &version);
// Previously the prototype after the colon was discarded entirely.
const EventDecl* versionReady = findEvent("versionReady");
ASSERT_NE(versionReady, nullptr)
<< "Same-line `logos_events :` prototype must still be parsed";
ASSERT_EQ(versionReady->params.size(), 1);
EXPECT_EQ(versionReady->params[0].name, "version");
EXPECT_EQ(versionReady->params[0].type.name, "tstr");
// The `///` doc comment above the collapsed line must attach: in the
// same-line form there is nowhere else for it to go, so documentation
// must not be formatting-dependent either.
EXPECT_EQ(versionReady->description, "Fired once the latest version is known.");
// An event declared after the section is already open, also same-line.
const EventDecl* downloadProgress = findEvent("downloadProgress");
ASSERT_NE(downloadProgress, nullptr);
ASSERT_EQ(downloadProgress->params.size(), 2);
EXPECT_EQ(downloadProgress->params[0].name, "id");
EXPECT_EQ(downloadProgress->params[0].type.name, "tstr");
EXPECT_EQ(downloadProgress->params[1].name, "percent");
EXPECT_EQ(downloadProgress->params[1].type.name, "int");
// The newline-separated form keeps working alongside the collapsed form.
EXPECT_NE(findEvent("shutdown"), nullptr);
// Exactly the three events above — no phantom or dropped entries.
EXPECT_EQ(r.module.events.size(), 3);
// The symmetric case: `public : <decl>` on one line must surface the
// method too (the access specifier no longer swallows the declaration).
const MethodDecl* greet = findMethod("greet");
ASSERT_NE(greet, nullptr)
<< "Same-line `public:` declaration must still be parsed";
EXPECT_EQ(greet->returnType.name, "tstr");
ASSERT_EQ(greet->params.size(), 1);
EXPECT_EQ(greet->params[0].name, "name");
EXPECT_EQ(greet->params[0].type.name, "tstr");
// The same-line events must land in events[], never leak into methods[].
EXPECT_EQ(findMethod("versionReady"), nullptr);
EXPECT_EQ(findMethod("downloadProgress"), nullptr);
}
TEST_F(ImplHeaderParserTest, ParsesMultiLineSignature)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = dir.filePath("ml_impl.h");
{
QFile f(hp);
ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(
"#pragma once\n"
"#include <string>\n"
"class MlImpl {\n"
"public:\n"
" std::string single(const std::string& a);\n"
" std::string wrapped(const std::string& first,\n"
" const std::string& second);\n"
"};\n");
}
auto r = parseImplHeader(hp, "MlImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
QStringList names;
for (const auto& m : r.module.methods) names << QString::fromStdString(m.name);
EXPECT_TRUE(names.contains("single"));
EXPECT_TRUE(names.contains("wrapped"))
<< "got: " << names.join(",").toStdString();
}
// ---------------------------------------------------------------------------
// Optionality, header-first
//
// `std::optional<T>` used to fall through to the opaque `any` fallback with no
// diagnostic, so a header-first C++ provider could not express an optional at
// all: it declared one and published a contract that said something else.
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, StdOptionalBecomesOptional)
{
auto r = parseImplHeader(
fixturesDir() + "/optional_impl.h",
"OptionalImpl",
fixturesDir() + "/optional_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
auto findType = [&](const char* n) -> const TypeDecl* {
for (const auto& t : r.module.types)
if (t.name == n) return &t;
return nullptr;
};
auto findMethod = [&](const char* n) -> const MethodDecl* {
for (const auto& m : r.module.methods)
if (m.name == n) return &m;
return nullptr;
};
const TypeDecl* profile = findType("Profile");
ASSERT_NE(profile, nullptr);
auto fieldNamed = [&](const char* n) -> const FieldDecl* {
for (const auto& f : profile->fields)
if (f.name == n) return &f;
return nullptr;
};
// A plain member stays required.
ASSERT_NE(fieldNamed("required"), nullptr);
EXPECT_FALSE(fieldIsOptional(*fieldNamed("required")));
// Optional members carry the value type, not `any`.
ASSERT_NE(fieldNamed("nickname"), nullptr);
EXPECT_TRUE(fieldIsOptional(*fieldNamed("nickname")));
EXPECT_EQ(fieldValueType(*fieldNamed("nickname")).name, "tstr");
EXPECT_EQ(fieldValueType(*fieldNamed("age")).name, "uint");
EXPECT_EQ(fieldValueType(*fieldNamed("avatar")).name, "bstr");
// Optional composes with a declared record — and `std::optional<Blob>` is
// still a MENTION of Blob, so the record survives the
// keep-only-referenced-records pass instead of being dropped as unused.
ASSERT_NE(fieldNamed("blob"), nullptr);
EXPECT_EQ(fieldValueType(*fieldNamed("blob")).kind, TypeExpr::Named);
EXPECT_EQ(fieldValueType(*fieldNamed("blob")).name, "Blob");
EXPECT_NE(findType("Blob"), nullptr);
// Parameters and returns, and optional nested inside a container.
const MethodDecl* echo = findMethod("echoOptional");
ASSERT_NE(echo, nullptr);
ASSERT_EQ(echo->params.size(), 1u);
EXPECT_TRUE(paramIsOptional(echo->params[0]));
EXPECT_EQ(paramValueType(echo->params[0]).name, "tstr");
EXPECT_TRUE(typeIsOptional(echo->returnType));
const MethodDecl* lst = findMethod("echoOptionalList");
ASSERT_NE(lst, nullptr);
ASSERT_EQ(lst->params.size(), 1u);
EXPECT_EQ(lst->params[0].type.kind, TypeExpr::Array);
ASSERT_EQ(lst->params[0].type.elements.size(), 1u);
EXPECT_EQ(lst->params[0].type.elements[0].kind, TypeExpr::Optional);
// A required method is untouched.
const MethodDecl* req = findMethod("required");
ASSERT_NE(req, nullptr);
EXPECT_FALSE(paramIsOptional(req->params[0]));
EXPECT_FALSE(typeIsOptional(req->returnType));
// The event parameter, likewise.
ASSERT_EQ(r.module.events.size(), 1u);
ASSERT_EQ(r.module.events[0].params.size(), 2u);
EXPECT_FALSE(paramIsOptional(r.module.events[0].params[0]));
EXPECT_TRUE(paramIsOptional(r.module.events[0].params[1]));
}
// std::optional<std::optional<T>> has NO LIDL type: three C++ states over a
// two-state wire. It maps down to `?T` — which is what makes the author's own
// declaration stop compiling against the generated codec, deliberately — and
// says so here, rather than leaving a conversion error in generated code.
TEST_F(ImplHeaderParserTest, NestedOptionalCollapsesAndIsReported)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = dir.filePath("nested_impl.h");
{
QFile f(hp);
ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(
"#pragma once\n"
"#include <optional>\n"
"#include <string>\n"
"struct Rec {\n"
" std::optional<std::optional<std::string>> collapsed;\n"
"};\n"
"class NestedImpl {\n"
"public:\n"
" Rec echo(const Rec& v);\n"
"};\n");
}
auto r = parseImplHeader(hp, "NestedImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
ASSERT_EQ(r.module.types.size(), 1u);
ASSERT_EQ(r.module.types[0].fields.size(), 1u);
const FieldDecl& f = r.module.types[0].fields[0];
EXPECT_TRUE(fieldIsOptional(f));
// Collapsed to ONE layer — the contract may not carry a third state.
EXPECT_EQ(fieldValueType(f).kind, TypeExpr::Primitive);
EXPECT_EQ(fieldValueType(f).name, "tstr");
err.flush();
EXPECT_TRUE(errOutput.contains("std::optional<std::optional<std::string>>"))
<< errOutput.toStdString();
EXPECT_TRUE(errOutput.contains("no LIDL type")) << errOutput.toStdString();
}
// A one-class header written to a temp dir and parsed, for the cases that are
// about a single declaration rather than a whole fixture module.
namespace {
QString probeHeader(QTemporaryDir& dir, const QString& body)
{
const QString hp = dir.filePath("probe_impl.h");
QFile f(hp);
if (!f.open(QIODevice::WriteOnly | QIODevice::Text))
return QString();
f.write(("#pragma once\n"
"#include <logos_json.h>\n"
"#include <cstdint>\n"
"#include <map>\n"
"#include <optional>\n"
"#include <set>\n"
"#include <string>\n"
"#include <unordered_map>\n"
"#include <utility>\n"
"#include <vector>\n"
+ body).toUtf8());
return hp;
}
} // namespace
// `nlohmann::json` reaches `any` only through the fallback, and `any` is the
// RIGHT answer for it — test_fullapi_cpp's echoAny / fireAnyEvent / anyEvent
// are the conformance matrix's `any` cells. Naming it is what lets the fallback
// become an error without taking them out.
TEST_F(ImplHeaderParserTest, NlohmannJsonIsAnyByName)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
"class ProbeImpl {\n"
"public:\n"
" nlohmann::json echoAny(const nlohmann::json& v);\n"
" bool fireAnyEvent(const nlohmann::json& v);\n"
"logos_events:\n"
" void anyEvent(const nlohmann::json& v);\n"
"};\n");
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
ASSERT_EQ(r.module.methods.size(), 2u);
EXPECT_EQ(r.module.methods[0].returnType.name, "any");
EXPECT_EQ(r.module.methods[0].params[0].type.name, "any");
EXPECT_EQ(r.module.methods[1].params[0].type.name, "any");
ASSERT_EQ(r.module.events.size(), 1u);
EXPECT_EQ(r.module.events[0].params[0].type.name, "any");
}
// ---------------------------------------------------------------------------
// A C++ spelling with no LIDL type is a BUILD ERROR, not a silent `any`.
//
// cppTypeToLidl used to end with `// Fallback: treat as opaque` -> `any`, and
// `any` is admitted by every backend gate. So an unrecognised spelling was
// accepted in silence, published as `any`, and dispatched as a raw
// `lidlImpl().f(args.at(0))` with no decode and no check.
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, NarrowNumericIsRejectedWithTheWidening)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
"class ProbeImpl {\n"
"public:\n"
" int64_t f(uint32_t depth);\n"
"};\n");
ASSERT_FALSE(hp.isEmpty());
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_TRUE(r.hasError()) << "uint32_t was admitted as `any`";
// Names the declaration, the offending type, and what to write instead.
EXPECT_TRUE(r.error.contains("method 'f': parameter 'depth'")) << r.error.toStdString();
EXPECT_TRUE(r.error.contains("`uint32_t`")) << r.error.toStdString();
EXPECT_TRUE(r.error.contains("`uint64_t`")) << r.error.toStdString();
}
// The offender may be nested. Report BOTH: the element with no LIDL type, and
// the declaration that carries it.
TEST_F(ImplHeaderParserTest, NestedOffenderNamesTheDeclarationToo)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
"class ProbeImpl {\n"
"public:\n"
" int64_t f(const std::vector<uint32_t>& instruction);\n"
"};\n");
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_TRUE(r.hasError());
EXPECT_TRUE(r.error.contains("const std::vector<uint32_t>&")) << r.error.toStdString();
EXPECT_TRUE(r.error.contains("`uint32_t`")) << r.error.toStdString();
}
// Each family gets a hint that names a replacement. A diagnostic without one
// just moves the guesswork.
TEST_F(ImplHeaderParserTest, EachUnsupportedFamilyNamesItsReplacement)
{
struct Case { const char* decl; const char* mentions; };
const Case cases[] = {
{"int64_t f(float v);", "`double`"},
{"int64_t f(uint8_t v);", "std::vector<uint8_t>"},
{"int64_t f(size_t v);", "`uint64_t`"},
{"int64_t f(const std::set<std::string>& v);", "std::vector<T>"},
{"int64_t f(const std::pair<std::string, std::string>& v);", "struct"},
{"int64_t f(const std::map<int64_t, std::string>& v);", "`tstr`"},
};
for (const Case& c : cases) {
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
QString("class ProbeImpl {\npublic:\n %1\n};\n").arg(c.decl));
QString e;
QTextStream es(&e);
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", es);
ASSERT_TRUE(r.hasError()) << c.decl;
EXPECT_TRUE(r.error.contains(c.mentions))
<< c.decl << "\n" << r.error.toStdString();
}
}
// std::unordered_map<std::string, T> is the second C++ spelling of `{tstr: T}`.
// logos_codec.h has always specialized Codec for it; the parser had not, so it
// published `any`.
TEST_F(ImplHeaderParserTest, UnorderedMapIsAStringKeyedMap)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
"class ProbeImpl {\n"
"public:\n"
" int64_t f(const std::unordered_map<std::string, std::string>& m);\n"
"};\n");
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
ASSERT_EQ(r.module.methods.size(), 1u);
const TypeExpr& t = r.module.methods[0].params[0].type;
EXPECT_EQ(t.kind, TypeExpr::Map);
ASSERT_EQ(t.elements.size(), 2u);
EXPECT_EQ(t.elements[0].name, "tstr");
EXPECT_EQ(t.elements[1].name, "tstr");
}
// ...except in the two slots whose C++ spelling the generator WRITES OUT — a
// record field's codec and an event's generated body. There it has to pick one
// container name, and picking the wrong one is a compile error in code the
// author never wrote. Say so at the declaration instead.
TEST_F(ImplHeaderParserTest, UnorderedMapIsRejectedWhereTheSpellingIsEmitted)
{
for (const char* body : {
"struct Rec {\n"
" std::unordered_map<std::string, std::string> m;\n"
"};\n"
"class ProbeImpl {\npublic:\n Rec echo(const Rec& v);\n};\n",
"class ProbeImpl {\n"
"public:\n"
" bool fire();\n"
"logos_events:\n"
" void changed(const std::unordered_map<std::string, std::string>& m);\n"
"};\n"}) {
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir, body);
QString e;
QTextStream es(&e);
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", es);
ASSERT_TRUE(r.hasError()) << body;
EXPECT_TRUE(r.error.contains("std::map<std::string, T>")) << r.error.toStdString();
}
}
// A helper struct the API never mentions is dropped from the contract, so an
// unsupported spelling INSIDE it promises nothing and must not fail the build.
// Publishing is what makes a declaration's type a promise.
TEST_F(ImplHeaderParserTest, UnreferencedHelperStructDoesNotFailTheBuild)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
"struct PendingAction {\n"
" uint32_t attempts;\n"
"};\n"
"class ProbeImpl {\n"
"public:\n"
" int64_t f(int64_t n);\n"
"};\n");
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
EXPECT_TRUE(r.module.types.empty());
// ...and the same struct DOES fail once a method publishes it.
QTemporaryDir dir2;
ASSERT_TRUE(dir2.isValid());
const QString hp2 = probeHeader(dir2,
"struct PendingAction {\n"
" uint32_t attempts;\n"
"};\n"
"class ProbeImpl {\n"
"public:\n"
" PendingAction f(int64_t n);\n"
"};\n");
QString e2;
QTextStream es2(&e2);
auto r2 = parseImplHeader(hp2, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", es2);
ASSERT_TRUE(r2.hasError());
EXPECT_TRUE(r2.error.contains("type 'PendingAction': field 'attempts'"))
<< r2.error.toStdString();
}
// The reserved LogosModuleContext hooks are framework plumbing, not contract.
// They are dropped after parsing, so their spellings are not a contract defect.
TEST_F(ImplHeaderParserTest, ReservedHookSpellingsAreNotContractDefects)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
"class ProbeImpl {\n"
"public:\n"
" void onContextReady(uint32_t generation);\n"
" int64_t f(int64_t n);\n"
"};\n");
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
ASSERT_EQ(r.module.methods.size(), 1u);
EXPECT_EQ(r.module.methods[0].name, "f");
}