From 1b265938339c8aa5286ce2bff6f970c0dbcb291a Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Sun, 2 Aug 2026 03:38:31 -0700 Subject: [PATCH 1/4] feat(capi): scn_check_dsl_file / _string and the scn_dsl_check handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking a DSL source needs no engine — it is a frontend service — so the result is its own opaque handle rather than an engine's diagnostic list. A failing check still produces one, because that is the case whose diagnostics a caller wants; only a NULL argument returns without it. A zero-initialized scn_dsl_check_options turns the standard library off, which is what a zero struct has to mean for an appendable ABI struct; NULL options therefore means the C++ defaults, library included. capi/ now links scena::frontend-dsl, PRIVATE like the XML frontend: no frontend type reaches capi.h, which stays C-clean. Refs #43 --- capi/CMakeLists.txt | 8 +- capi/include/scena/capi.h | 83 ++++++++++++++++++++ capi/src/capi.cpp | 144 ++++++++++++++++++++++++++++++++++ capi/tests/c_consumer.c | 76 ++++++++++++++++++ core/tests/capi_test.cpp | 158 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 465 insertions(+), 4 deletions(-) diff --git a/capi/CMakeLists.txt b/capi/CMakeLists.txt index 4ae11bc..f6ea803 100644 --- a/capi/CMakeLists.txt +++ b/capi/CMakeLists.txt @@ -8,10 +8,10 @@ add_library(scena::capi ALIAS scena-capi) target_include_directories(scena-capi PUBLIC $ ) -# The C ABI exposes scenario loading (scn_engine_load_xml_*), so it links the -# XML frontend as well. Both are PRIVATE: no frontend or kernel type appears in -# capi.h, which stays C-clean. -target_link_libraries(scena-capi PRIVATE scena::core scena::frontend-xml) +# The C ABI exposes scenario loading (scn_engine_load_xml_*) and DSL checking +# (scn_check_dsl_*), so it links both frontends as well. All PRIVATE: no +# frontend or kernel type appears in capi.h, which stays C-clean. +target_link_libraries(scena-capi PRIVATE scena::core scena::frontend-xml scena::frontend-dsl) target_compile_definitions(scena-capi PRIVATE SCN_CAPI_EXPORTS) target_compile_features(scena-capi PRIVATE cxx_std_20) set_target_properties(scena-capi PROPERTIES diff --git a/capi/include/scena/capi.h b/capi/include/scena/capi.h index 3caadf7..3c07a77 100644 --- a/capi/include/scena/capi.h +++ b/capi/include/scena/capi.h @@ -1233,6 +1233,89 @@ SCN_API scn_status scn_engine_get_time(scn_engine* engine, double* out); /* Writes 1 into *out between a successful init and close, else 0. */ SCN_API scn_status scn_engine_initialized(scn_engine* engine, int* out); +/* --- Checking OpenSCENARIO DSL (p7-s5) ------------------------------------ */ + +/* The result of checking one OpenSCENARIO DSL source: its findings and what the + * checker made of it. Opaque; create with scn_check_dsl_file or + * scn_check_dsl_string and release with scn_dsl_check_destroy. + * + * Checking is not loading. A DSL source that checks clean is one the frontend + * understood — executing it is P8, and no engine is involved here, which is why + * this surface has its own handle rather than hanging off scn_engine. */ +typedef struct scn_dsl_check scn_dsl_check; + +/* How a check resolves imports (§7.7.5). + * + * Zero-initialize it (`scn_dsl_check_options options = {0};`) and then set what + * you need, so fields added by a later ABI minor stay zero. Note that a + * zero-initialized struct turns the standard library OFF; pass 1 to get the + * behavior scena-check has by default. + * + * Transparent struct: the layout is frozen ABI. Append fields only. */ +typedef struct scn_dsl_check_options { + /* Directories a module reference is resolved against, in order; a + * reference `a.b.c` is looked up as /a/b/c.osc. NULL when there are + * none. The array and the strings are borrowed for the duration of the + * call only. */ + const char* const* search_paths; + size_t search_path_count; + /* Non-zero makes the bundled osc.standard library available without an + * import, which is what gives a literal like `30kph` a type (§7.7.5.2). */ + int implicit_standard_library; +} scn_dsl_check_options; + +/* Checks the OpenSCENARIO DSL source at `path`, following its imports. + * + * `options` may be NULL, which means default options WITH the standard library + * — the same defaults the C++ LoadOptions carries, not the zero struct. + * + * On any return other than SCN_ERROR_INVALID_ARGUMENT, *out_check holds a + * handle the caller must release with scn_dsl_check_destroy, whatever the + * status: a failing check is exactly the case whose diagnostics you want. A + * NULL path or out_check is rejected with SCN_ERROR_INVALID_ARGUMENT and + * *out_check is left untouched. + * + * The return value is the check's own outcome: SCN_OK when nothing was reported + * as an error, SCN_ERROR_INVALID_ARGUMENT when the file could not be read at + * all (host misuse — and the only case where a handle is still produced, so + * test out_check rather than the status to tell the two apart), and otherwise + * the first error's status. */ +SCN_API scn_status scn_check_dsl_file(const char* path, const scn_dsl_check_options* options, + scn_dsl_check** out_check); + +/* As scn_check_dsl_file, from a NUL-terminated source in memory. + * + * `origin` names the source in diagnostics and anchors its relative imports; it + * need not exist on disk. NULL means "". */ +SCN_API scn_status scn_check_dsl_string(const char* source, const char* origin, + const scn_dsl_check_options* options, + scn_dsl_check** out_check); + +/* Writes the number of diagnostics the check reported into *out_count. */ +SCN_API scn_status scn_dsl_check_diagnostic_count(scn_dsl_check* check, size_t* out_count); + +/* Writes the diagnostic at `index` into *out. An index >= the count returns + * SCN_ERROR_INVALID_ARGUMENT with *out left untouched. + * + * The borrowed strings stay valid until scn_dsl_check_destroy — a check result + * is immutable, so unlike the engine's diagnostics nothing else invalidates + * them. DSL diagnostics cite a specification section in their message and leave + * rule_id empty, because the DSL standard defines no `asam.net:` rule ids. */ +SCN_API scn_status scn_dsl_check_diagnostic_at(scn_dsl_check* check, size_t index, + scn_diagnostic* out); + +/* Writes the number of types the check resolved into *out_count — every type + * the source declares plus every one it reached through an import, the standard + * library included. */ +SCN_API scn_status scn_dsl_check_type_count(scn_dsl_check* check, size_t* out_count); + +/* Writes the number of source files the check covered into *out_count: the file + * itself plus everything it imported, transitively. */ +SCN_API scn_status scn_dsl_check_file_count(scn_dsl_check* check, size_t* out_count); + +/* Releases a check result. NULL is accepted and does nothing. */ +SCN_API void scn_dsl_check_destroy(scn_dsl_check* check); + #ifdef __cplusplus } #endif diff --git a/capi/src/capi.cpp b/capi/src/capi.cpp index 1911806..f0456c2 100644 --- a/capi/src/capi.cpp +++ b/capi/src/capi.cpp @@ -25,6 +25,8 @@ #include #include "scena/diagnostic.h" +#include "scena/dsl/load.h" +#include "scena/dsl/types.h" #include "scena/engine.h" #include "scena/entity_visibility.h" #include "scena/gateway/simulator_gateway.h" @@ -154,6 +156,21 @@ class CCallbackGateway final : public scena::gateway::ISimulatorGateway { bool installed_ = false; }; +/// One completed DSL check: the diagnostics it reported and the two counts a +/// caller can ask about. +/// +/// The `LoadResult` owns the ASTs a `Program` points into, so it has to outlive +/// the `Program` — declaration order here is the guarantee. Nothing mutates +/// after scn_check_dsl_* returns, which is why the borrowed diagnostic strings +/// stay valid for the whole life of the handle. +struct scn_dsl_check { + scena::dsl::LoadResult loaded; + scena::dsl::Program program; + std::vector diagnostics; + size_t type_count = 0; + size_t file_count = 0; +}; + struct scn_engine { scena::ir::Scenario scenario; CCallbackGateway callbacks; @@ -2142,3 +2159,130 @@ scn_status scn_engine_set_callbacks(scn_engine* engine, const scn_callbacks* cal engine->callbacks.mark_installed(true); return SCN_OK; } + +// --- Checking OpenSCENARIO DSL --------------------------------------------- + +namespace { + +/// Translates the C options struct, or supplies the C++ defaults for NULL. +/// +/// A NULL `options` is not the zero struct: the zero struct turns the standard +/// library off, and the documented meaning of NULL is "the defaults", which +/// include it (see capi.h). +scena::dsl::LoadOptions to_load_options(const scn_dsl_check_options* options) { + scena::dsl::LoadOptions load_options; + if (options == nullptr) { + return load_options; + } + load_options.implicit_standard_library = options->implicit_standard_library != 0; + if (options->search_paths != nullptr) { + for (size_t index = 0; index < options->search_path_count; ++index) { + const char* directory = options->search_paths[index]; + if (directory != nullptr) { + load_options.search_paths.emplace_back(directory); + } + } + } + return load_options; +} + +/// Records what the check found, so the handle answers every query without +/// touching the resolver again. +void finish_check(scn_dsl_check& check, scena::DiagnosticSink& sink) { + check.diagnostics = sink.take(); + check.type_count = check.program.types.size(); + check.file_count = check.loaded.files().size(); +} + +} // namespace + +scn_status scn_check_dsl_file(const char* path, const scn_dsl_check_options* options, + scn_dsl_check** out_check) { + if (path == nullptr || out_check == nullptr) { + return SCN_ERROR_INVALID_ARGUMENT; + } + try { + auto check = std::make_unique(); + scena::DiagnosticSink sink; + const scena::Status status = + scena::dsl::check_file(std::filesystem::path(path), to_load_options(options), + check->loaded, check->program, sink); + finish_check(*check, sink); + *out_check = check.release(); + return to_c_status(status); + } catch (const std::bad_alloc&) { + return SCN_ERROR_INTERNAL; + } catch (...) { + return SCN_ERROR_INTERNAL; + } +} + +scn_status scn_check_dsl_string(const char* source, const char* origin, + const scn_dsl_check_options* options, scn_dsl_check** out_check) { + if (source == nullptr || out_check == nullptr) { + return SCN_ERROR_INVALID_ARGUMENT; + } + try { + auto check = std::make_unique(); + scena::DiagnosticSink sink; + const std::filesystem::path where(origin == nullptr ? "" : origin); + const scena::Status status = scena::dsl::check_source( + source, where, to_load_options(options), check->loaded, check->program, sink); + finish_check(*check, sink); + *out_check = check.release(); + return to_c_status(status); + } catch (const std::bad_alloc&) { + return SCN_ERROR_INTERNAL; + } catch (...) { + return SCN_ERROR_INTERNAL; + } +} + +scn_status scn_dsl_check_diagnostic_count(scn_dsl_check* check, size_t* out_count) { + if (check == nullptr || out_count == nullptr) { + return SCN_ERROR_INVALID_ARGUMENT; + } + *out_count = check->diagnostics.size(); + return SCN_OK; +} + +scn_status scn_dsl_check_diagnostic_at(scn_dsl_check* check, size_t index, scn_diagnostic* out) { + if (check == nullptr || out == nullptr) { + return SCN_ERROR_INVALID_ARGUMENT; + } + if (index >= check->diagnostics.size()) { + return SCN_ERROR_INVALID_ARGUMENT; // out left untouched + } + const scena::Diagnostic& diagnostic = check->diagnostics[index]; + // Strings borrow from the diagnostic's std::strings, which the handle owns + // and never mutates — valid until scn_dsl_check_destroy (see capi.h). + out->severity = to_c_severity(diagnostic.severity); + out->code = to_c_status(diagnostic.code); + out->message = diagnostic.message.c_str(); + out->path = diagnostic.path.c_str(); + out->file = diagnostic.location.file.c_str(); + out->line = diagnostic.location.line; + out->column = diagnostic.location.column; + out->rule_id = diagnostic.rule_id.c_str(); + return SCN_OK; +} + +scn_status scn_dsl_check_type_count(scn_dsl_check* check, size_t* out_count) { + if (check == nullptr || out_count == nullptr) { + return SCN_ERROR_INVALID_ARGUMENT; + } + *out_count = check->type_count; + return SCN_OK; +} + +scn_status scn_dsl_check_file_count(scn_dsl_check* check, size_t* out_count) { + if (check == nullptr || out_count == nullptr) { + return SCN_ERROR_INVALID_ARGUMENT; + } + *out_count = check->file_count; + return SCN_OK; +} + +void scn_dsl_check_destroy(scn_dsl_check* check) { + delete check; +} diff --git a/capi/tests/c_consumer.c b/capi/tests/c_consumer.c index 239c3ff..0db1c0b 100644 --- a/capi/tests/c_consumer.c +++ b/capi/tests/c_consumer.c @@ -77,6 +77,79 @@ static const char* const kScenario = " " ""; +/* OpenSCENARIO DSL, checked through scn_check_dsl_string. `length` comes from + * the bundled standard library, so this source also proves the implicit import + * reached the check. */ +static const char* const kDslSource = "struct marker:\n" + " x: length\n" + " y: length\n"; + +/* The same source with a type nothing declares — one error, one diagnostic. */ +static const char* const kBadDslSource = "struct marker:\n" + " x: no_such_type\n"; + +static int check_dsl_surface(void) { + scn_dsl_check_options options; + scn_dsl_check* check = NULL; + size_t diagnostic_count = 1; + size_t type_count = 0; + size_t file_count = 0; + scn_diagnostic diagnostic; + + memset(&options, 0, sizeof(options)); + options.implicit_standard_library = 1; + + CHECK(scn_check_dsl_string(kDslSource, "marker.osc", &options, &check) == SCN_OK); + CHECK(check != NULL); + CHECK(scn_dsl_check_diagnostic_count(check, &diagnostic_count) == SCN_OK); + CHECK(diagnostic_count == 0); + CHECK(scn_dsl_check_type_count(check, &type_count) == SCN_OK); + CHECK(type_count > 0); + /* The source itself plus the standard library's two sub-modules. */ + CHECK(scn_dsl_check_file_count(check, &file_count) == SCN_OK); + CHECK(file_count > 1); + scn_dsl_check_destroy(check); + + /* A failing check still produces a handle: the diagnostics are the point. */ + check = NULL; + CHECK(scn_check_dsl_string(kBadDslSource, "marker.osc", NULL, &check) != SCN_OK); + CHECK(check != NULL); + CHECK(scn_dsl_check_diagnostic_count(check, &diagnostic_count) == SCN_OK); + CHECK(diagnostic_count > 0); + memset(&diagnostic, 0, sizeof(diagnostic)); + CHECK(scn_dsl_check_diagnostic_at(check, 0, &diagnostic) == SCN_OK); + CHECK(diagnostic.severity == SCN_SEVERITY_ERROR); + CHECK(strcmp(diagnostic.file, "marker.osc") == 0); + CHECK(diagnostic.line > 0); + /* The DSL standard defines no rule ids; the citation is in the message. */ + CHECK(strcmp(diagnostic.rule_id, "") == 0); + CHECK(scn_dsl_check_diagnostic_at(check, diagnostic_count, &diagnostic) == + SCN_ERROR_INVALID_ARGUMENT); + scn_dsl_check_destroy(check); + + /* A path that cannot be read is host misuse, and still hands back the + * handle carrying the diagnostic that says so. */ + check = NULL; + CHECK(scn_check_dsl_file("no/such/file.osc", NULL, &check) == SCN_ERROR_INVALID_ARGUMENT); + CHECK(check != NULL); + CHECK(scn_dsl_check_diagnostic_count(check, &diagnostic_count) == SCN_OK); + CHECK(diagnostic_count > 0); + scn_dsl_check_destroy(check); + + /* Null arguments are rejected without producing a handle. */ + check = NULL; + CHECK(scn_check_dsl_string(NULL, NULL, NULL, &check) == SCN_ERROR_INVALID_ARGUMENT); + CHECK(check == NULL); + CHECK(scn_check_dsl_file(NULL, NULL, &check) == SCN_ERROR_INVALID_ARGUMENT); + CHECK(check == NULL); + CHECK(scn_check_dsl_string(kDslSource, NULL, NULL, NULL) == SCN_ERROR_INVALID_ARGUMENT); + CHECK(scn_dsl_check_diagnostic_count(NULL, &diagnostic_count) == SCN_ERROR_INVALID_ARGUMENT); + /* Destroying NULL is a no-op, so a cleanup path needs no guard. */ + scn_dsl_check_destroy(NULL); + + return 0; +} + int main(void) { /* An embedder that dlopen()s the library checks the ABI major first. */ CHECK(scn_abi_version() / 10000u == SCN_ABI_VERSION / 10000u); @@ -150,6 +223,9 @@ int main(void) { CHECK(scn_engine_close(engine) == SCN_OK); scn_engine_destroy(engine); + /* Checking DSL needs no engine at all — it is a frontend service. */ + CHECK(check_dsl_surface() == 0); + printf("pure-C consumer: OK\n"); return 0; } diff --git a/core/tests/capi_test.cpp b/core/tests/capi_test.cpp index ae7674c..8766b70 100644 --- a/core/tests/capi_test.cpp +++ b/core/tests/capi_test.cpp @@ -17,6 +17,8 @@ #include "scena/capi.h" #include +#include +#include #include #include @@ -1841,6 +1843,162 @@ TEST(CApiTest, EveryNewEntryPointRejectsNullArguments) { scn_engine_destroy(engine); } +// --- p7-s5: checking OpenSCENARIO DSL through the C ABI --------------------- + +namespace { + +// `length` comes from the bundled standard library, so a clean check here also +// proves the implicit import reached it (§7.7.5.2). +constexpr const char* kDslSource = "struct marker:\n x: length\n"; +constexpr const char* kBadDslSource = "struct marker:\n x: no_such_type\n"; + +} // namespace + +TEST(CApiTest, DslCheckReportsWhatItCovered) { + scn_dsl_check_options options{}; + options.implicit_standard_library = 1; + + scn_dsl_check* check = nullptr; + ASSERT_EQ(scn_check_dsl_string(kDslSource, "marker.osc", &options, &check), SCN_OK); + ASSERT_NE(check, nullptr); + + size_t count = 1; + EXPECT_EQ(scn_dsl_check_diagnostic_count(check, &count), SCN_OK); + EXPECT_EQ(count, 0U); + size_t types = 0; + EXPECT_EQ(scn_dsl_check_type_count(check, &types), SCN_OK); + EXPECT_GT(types, 0U); + // The source itself plus the standard library it was given implicitly. + size_t files = 0; + EXPECT_EQ(scn_dsl_check_file_count(check, &files), SCN_OK); + EXPECT_GT(files, 1U); + + scn_dsl_check_destroy(check); +} + +TEST(CApiTest, DslCheckWithoutTheStandardLibraryStandsAlone) { + // The zero struct turns the library off, which is the documented meaning of + // a zero-initialized options struct — and why NULL means the defaults + // instead. + const scn_dsl_check_options options{}; + scn_dsl_check* check = nullptr; + EXPECT_NE(scn_check_dsl_string(kDslSource, "marker.osc", &options, &check), SCN_OK); + ASSERT_NE(check, nullptr); + size_t files = 0; + EXPECT_EQ(scn_dsl_check_file_count(check, &files), SCN_OK); + EXPECT_EQ(files, 1U); + scn_dsl_check_destroy(check); +} + +TEST(CApiTest, AFailingDslCheckStillHandsBackItsDiagnostics) { + scn_dsl_check* check = nullptr; + // NULL options means the defaults, standard library included. + EXPECT_NE(scn_check_dsl_string(kBadDslSource, "marker.osc", nullptr, &check), SCN_OK); + ASSERT_NE(check, nullptr); + + size_t count = 0; + ASSERT_EQ(scn_dsl_check_diagnostic_count(check, &count), SCN_OK); + ASSERT_GT(count, 0U); + + scn_diagnostic diagnostic{}; + ASSERT_EQ(scn_dsl_check_diagnostic_at(check, 0, &diagnostic), SCN_OK); + EXPECT_EQ(diagnostic.severity, SCN_SEVERITY_ERROR); + // A Program spans every file its root imported, so a diagnostic names the + // file it came from and not just a line. + EXPECT_STREQ(diagnostic.file, "marker.osc"); + EXPECT_GT(diagnostic.line, 0); + // The DSL standard defines no `asam.net:` rule ids; the citation is in the + // message. + EXPECT_STREQ(diagnostic.rule_id, ""); + EXPECT_NE(std::string(diagnostic.message).find("no_such_type"), std::string::npos); + + // Out of range leaves the caller's struct alone. + EXPECT_EQ(scn_dsl_check_diagnostic_at(check, count, &diagnostic), SCN_ERROR_INVALID_ARGUMENT); + EXPECT_STREQ(diagnostic.file, "marker.osc"); + + scn_dsl_check_destroy(check); +} + +TEST(CApiTest, DslCheckResolvesImportsThroughSearchPaths) { + const std::filesystem::path root = + std::filesystem::temp_directory_path() / "scena_capi_dsl_check"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root / "shapes"); + { + std::ofstream out(root / "shapes" / "basic.osc"); + out << kDslSource; + } + const std::filesystem::path top = root / "top.osc"; + { + std::ofstream out(top); + out << "import shapes.basic\n\nstruct pair:\n a: marker\n"; + } + + const std::string directory = root.string(); + const char* search_paths[] = {directory.c_str()}; + scn_dsl_check_options options{}; + options.implicit_standard_library = 1; + options.search_paths = search_paths; + options.search_path_count = 1; + + const std::string path = top.string(); + scn_dsl_check* check = nullptr; + EXPECT_EQ(scn_check_dsl_file(path.c_str(), &options, &check), SCN_OK); + ASSERT_NE(check, nullptr); + scn_dsl_check_destroy(check); + + // Without the search path the import is unresolvable, and the check says so + // rather than silently declaring nothing. + options.search_paths = nullptr; + options.search_path_count = 0; + check = nullptr; + EXPECT_NE(scn_check_dsl_file(path.c_str(), &options, &check), SCN_OK); + ASSERT_NE(check, nullptr); + scn_dsl_check_destroy(check); + + std::filesystem::remove_all(root); +} + +TEST(CApiTest, AnUnreadableDslPathIsHostMisuseAndStillProducesAHandle) { + scn_dsl_check* check = nullptr; + EXPECT_EQ(scn_check_dsl_file("no/such/file.osc", nullptr, &check), SCN_ERROR_INVALID_ARGUMENT); + ASSERT_NE(check, nullptr); + size_t count = 0; + EXPECT_EQ(scn_dsl_check_diagnostic_count(check, &count), SCN_OK); + EXPECT_GT(count, 0U); + scn_dsl_check_destroy(check); +} + +TEST(CApiTest, EveryDslCheckEntryPointRejectsNullArguments) { + // Same sweep the engine's entry points get. A null argument is rejected + // WITHOUT producing a handle, which is what lets a caller tell it apart + // from an unreadable path (both answer SCN_ERROR_INVALID_ARGUMENT). + scn_dsl_check* check = nullptr; + size_t count = 0; + scn_diagnostic diagnostic{}; + + EXPECT_EQ(scn_check_dsl_file(nullptr, nullptr, &check), SCN_ERROR_INVALID_ARGUMENT); + EXPECT_EQ(check, nullptr); + EXPECT_EQ(scn_check_dsl_file("x.osc", nullptr, nullptr), SCN_ERROR_INVALID_ARGUMENT); + EXPECT_EQ(scn_check_dsl_string(nullptr, nullptr, nullptr, &check), SCN_ERROR_INVALID_ARGUMENT); + EXPECT_EQ(check, nullptr); + EXPECT_EQ(scn_check_dsl_string(kDslSource, nullptr, nullptr, nullptr), + SCN_ERROR_INVALID_ARGUMENT); + + EXPECT_EQ(scn_dsl_check_diagnostic_count(nullptr, &count), SCN_ERROR_INVALID_ARGUMENT); + EXPECT_EQ(scn_dsl_check_diagnostic_at(nullptr, 0, &diagnostic), SCN_ERROR_INVALID_ARGUMENT); + EXPECT_EQ(scn_dsl_check_type_count(nullptr, &count), SCN_ERROR_INVALID_ARGUMENT); + EXPECT_EQ(scn_dsl_check_file_count(nullptr, &count), SCN_ERROR_INVALID_ARGUMENT); + + // Destroying NULL is a no-op, so a cleanup path needs no guard. + scn_dsl_check_destroy(nullptr); + + // A NULL origin names the source "" rather than leaving it blank. + ASSERT_EQ(scn_check_dsl_string(kDslSource, nullptr, nullptr, &check), SCN_OK); + ASSERT_NE(check, nullptr); + scn_dsl_check_destroy(check); +} + // --- p6-s2: gateway callbacks through the C ABI ---------------------------- namespace { From ed11e86c1e2b2aaff77ed5388315d2a01d51cbbc Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Sun, 2 Aug 2026 03:38:40 -0700 Subject: [PATCH 2/4] feat(python): check_dsl_file / check_dsl_string returning a DslCheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The XML loaders return (status, scenario) because the scenario is the payload and the findings are secondary. A check has no payload — the findings are the result, alongside how far the checker got — so it returns one named object instead of a four-tuple. DslCheck carries status, diagnostics, type_count and file_count, and is falsy unless the status is Ok. python/examples/check_dsl.py runs in CI: a clean source, a defective one, and an import resolved through a search path. Refs #43 --- .github/workflows/ci.yml | 1 + python/CMakeLists.txt | 7 +- python/examples/check_dsl.py | 99 ++++++++++++++++++++++++ python/src/bindings.cpp | 86 +++++++++++++++++++++ python/src/scena/__init__.py | 6 ++ python/tests/test_dsl_check.py | 134 +++++++++++++++++++++++++++++++++ python/tests/test_parity.py | 30 ++++++-- 7 files changed, 354 insertions(+), 9 deletions(-) create mode 100644 python/examples/check_dsl.py create mode 100644 python/tests/test_dsl_check.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9818e97..d6bc87b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,7 @@ jobs: python python/examples/load_and_run.py python python/examples/host_controlled.py python python/examples/storyboard_events.py + python python/examples/check_dsl.py - name: Parity audit (C++ / C / Python) run: python scripts/parity_audit.py diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index d6d4c5c..a5f20a9 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -3,9 +3,10 @@ # Python bindings: nanobind extension module `scena._scena`. nanobind_add_module(_scena src/bindings.cpp) -# The bindings expose scenario loading, so they link the XML frontend too — -# the same reason capi/ does (p6-s1). Both PRIVATE: nothing leaks into a header. -target_link_libraries(_scena PRIVATE scena::core scena::frontend-xml) +# The bindings expose scenario loading and DSL checking, so they link both +# frontends too — the same reason capi/ does (p6-s1). All PRIVATE: nothing +# leaks into a header. +target_link_libraries(_scena PRIVATE scena::core scena::frontend-xml scena::frontend-dsl) # Stage an importable package under /python so examples and tests can # run against the build tree: PYTHONPATH=/python. diff --git a/python/examples/check_dsl.py b/python/examples/check_dsl.py new file mode 100644 index 0000000..a15c6c9 --- /dev/null +++ b/python/examples/check_dsl.py @@ -0,0 +1,99 @@ +# Copyright 2026 Robomous +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Checking OpenSCENARIO DSL sources from Python (p7-s5). + +The same work `scena-check` does on the command line, reachable from a build +script or an editor integration that wants the findings as objects rather than +as text. + +Checking is not running: DSL execution is P8. A source that checks clean here is +one the frontend understood — its types resolve, its expressions type, its +imports were found — not one that will necessarily execute. + +Three things this shows: + +- a clean source, and the two counts that say how much the checker covered; +- a source with a defect, and what a diagnostic carries (severity, file, line, + and a message citing the specification section — DSL diagnostics leave + `rule_id` empty because the standard defines no `asam.net:` rule ids); +- an import resolved through a search path, the DSL's answer to "where do my + other files live" (§7.7.5.1.2). +""" + +import tempfile +from pathlib import Path + +import scena as scn + +# `length` and `speed` are physical types from the bundled standard library, +# which a check makes available without an import (§7.7.5.2) — that is what +# gives a literal like `30kph` a type at all. +BEACON = """\ +struct marker: + x: length + y: length + +actor beacon: + reach: length + top_speed: speed +""" + +MISSPELLED = """\ +struct marker: + x: lenght +""" + + +def report(label: str, result: "scn.DslCheck") -> None: + print(f"{label}: {result.status}") + for diagnostic in result.diagnostics: + where = diagnostic.location + print(f" {diagnostic.severity} {where.file}:{where.line}:{where.column}: " + f"{diagnostic.message}") + + +def main() -> None: + clean = scn.check_dsl_string(BEACON, "beacon.osc") + report("beacon.osc", clean) + print(f" {clean.type_count} types across {clean.file_count} files") + assert clean, "the example source should check clean" + + # A failing check is not an exception: the findings are the result, so the + # call returns them the same way it returns a success. + broken = scn.check_dsl_string(MISSPELLED, "misspelled.osc") + report("misspelled.osc", broken) + assert not broken, "a misspelled type should be an error" + + # Imports resolve against the search paths, in the order given: a reference + # `shapes.basic` is looked up as /shapes/basic.osc. + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "shapes").mkdir() + (root / "shapes" / "basic.osc").write_text(BEACON, encoding="utf-8") + top = root / "top.osc" + top.write_text("import shapes.basic\n\nstruct pair:\n a: marker\n", encoding="utf-8") + + missing = scn.check_dsl_file(top) + report("top.osc (no search path)", missing) + assert not missing, "the import cannot be resolved without a search path" + + found = scn.check_dsl_file(top, search_paths=[root]) + report("top.osc (with search path)", found) + print(f" {found.type_count} types across {found.file_count} files") + assert found, "the import resolves once the search path is given" + + +if __name__ == "__main__": + main() diff --git a/python/src/bindings.cpp b/python/src/bindings.cpp index b1f2e5b..7556c87 100644 --- a/python/src/bindings.cpp +++ b/python/src/bindings.cpp @@ -35,6 +35,8 @@ #include #include "scena/diagnostic.h" +#include "scena/dsl/load.h" +#include "scena/dsl/types.h" #include "scena/engine.h" #include "scena/entity_visibility.h" #include "scena/gateway/simulator_gateway.h" @@ -68,6 +70,41 @@ namespace ir = scena::ir; namespace { +/// What `check_dsl_file` / `check_dsl_string` hand back. +/// +/// A plain value, not a handle: the `Program` and the ASTs it points into stay +/// inside the check, so nothing here can outlive its own storage. Python gets +/// the findings and the two counts, which is everything the checker knows that +/// is meaningful without a lowering pass (that is P8). +struct DslCheck { + scena::Status status = scena::Status::Ok; + std::vector diagnostics; + size_t type_count = 0; + size_t file_count = 0; +}; + +/// Runs one check. `source` present means check it in memory, absent means read +/// `origin` from disk — the two entry points differ only there. +DslCheck run_dsl_check(std::optional source, const std::filesystem::path& origin, + const std::vector& search_paths, + bool implicit_standard_library) { + scena::dsl::LoadOptions options; + options.search_paths = search_paths; + options.implicit_standard_library = implicit_standard_library; + + scena::dsl::LoadResult loaded; + scena::dsl::Program program; + scena::DiagnosticSink sink; + DslCheck result; + result.status = source.has_value() + ? scena::dsl::check_source(*source, origin, options, loaded, program, sink) + : scena::dsl::check_file(origin, options, loaded, program, sink); + result.diagnostics = sink.take(); + result.type_count = program.types.size(); + result.file_count = loaded.files().size(); + return result; +} + /// nanobind trampoline for ISimulatorGateway: a Python class deriving from /// scena.SimulatorGateway overrides whichever methods it cares about, and the /// engine calls them through here. @@ -2026,4 +2063,53 @@ NB_MODULE(_scena, m) { return std::make_tuple(status, std::move(document.scenario), sink.take()); }, "xml"_a, "As load_string, additionally returning the list of findings."); + + // --- Checking OpenSCENARIO DSL ---------------------------------------- + + // The XML loaders return (status, scenario) because the scenario is the + // payload and the findings are secondary. A check has no payload — the + // findings ARE the result, alongside how much the checker got through — so + // it returns one named object instead of a four-tuple. + nb::class_(m, "DslCheck", + "The result of checking an OpenSCENARIO DSL source: what the checker " + "found, and how far it got.") + .def_ro("status", &DslCheck::status, "Ok when nothing was reported as an error.") + .def_ro("diagnostics", &DslCheck::diagnostics, + "Every finding, in the order it was reported. DSL diagnostics cite a " + "specification section in their message and leave rule_id empty, because the " + "DSL standard defines no asam.net rule ids.") + .def_ro("type_count", &DslCheck::type_count, + "Types resolved: everything the source declares plus everything it reached " + "through an import, the standard library included.") + .def_ro("file_count", &DslCheck::file_count, + "Source files covered: the file itself plus its transitive imports.") + .def("__bool__", [](const DslCheck& check) { return check.status == scena::Status::Ok; }) + .def("__repr__", [](const DslCheck& check) { + return nb::str("DslCheck(status={}, diagnostics={}, type_count={}, file_count={})") + .format(nb::cast(check.status), check.diagnostics.size(), check.type_count, + check.file_count); + }); + + m.def( + "check_dsl_file", + [](const std::filesystem::path& path, + const std::vector& search_paths, bool implicit_standard_library) { + return run_dsl_check(std::nullopt, path, search_paths, implicit_standard_library); + }, + "path"_a, "search_paths"_a = std::vector{}, + "implicit_standard_library"_a = true, + "Checks an OpenSCENARIO DSL file and everything it imports (§7.7.5). Checking is " + "not running: DSL execution is P8, so a file that checks clean is one the frontend " + "understood."); + m.def( + "check_dsl_string", + [](std::string_view source, const std::filesystem::path& origin, + const std::vector& search_paths, bool implicit_standard_library) { + return run_dsl_check(source, origin, search_paths, implicit_standard_library); + }, + "source"_a, "origin"_a = std::filesystem::path(""), + "search_paths"_a = std::vector{}, + "implicit_standard_library"_a = true, + "As check_dsl_file, from a source in memory. `origin` names it in diagnostics and " + "anchors its relative imports; it need not exist on disk."); } diff --git a/python/src/scena/__init__.py b/python/src/scena/__init__.py index d5eb55d..89a31eb 100644 --- a/python/src/scena/__init__.py +++ b/python/src/scena/__init__.py @@ -20,10 +20,13 @@ """ from ._scena import ( + check_dsl_file, + check_dsl_string, load_file, load_file_with_diagnostics, load_string, load_string_with_diagnostics, + DslCheck, AbsoluteTargetLane, AbsoluteTargetLaneOffset, AccelerationCondition, @@ -272,6 +275,9 @@ "SimulatorGateway", "SpeedProfileAction", "SpeedProfileEntry", + "check_dsl_file", + "check_dsl_string", + "DslCheck", "load_file", "load_file_with_diagnostics", "load_string", diff --git a/python/tests/test_dsl_check.py b/python/tests/test_dsl_check.py new file mode 100644 index 0000000..e8135ce --- /dev/null +++ b/python/tests/test_dsl_check.py @@ -0,0 +1,134 @@ +# Copyright 2026 Robomous +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Checking OpenSCENARIO DSL from Python (p7-s5). + +Checking is not running: DSL execution is P8. A source that checks clean here is +one the frontend understood, and these tests pin what the binding reports about +it — the status, the findings, and how far the checker got. +""" + +import scena as scn + +# `length` and `speed` come from the bundled standard library, so a source that +# uses them also proves the implicit import reached the check (§7.7.5.2). +CLEAN = """\ +struct marker: + x: length + y: length + +actor beacon: + reach: length + top_speed: speed +""" + +UNKNOWN_TYPE = """\ +struct marker: + x: no_such_type +""" + + +def test_a_clean_source_checks_ok() -> None: + result = scn.check_dsl_string(CLEAN) + assert result.status == scn.Status.Ok + assert result.diagnostics == [] + assert bool(result) is True + + +def test_the_check_counts_what_it_covered() -> None: + result = scn.check_dsl_string(CLEAN) + # The source itself plus the standard library it was given implicitly. + assert result.file_count > 1 + # Every type the source declares plus everything the library brought. + assert result.type_count > 2 + + +def test_an_unknown_type_is_an_error_that_cites_its_section() -> None: + result = scn.check_dsl_string(UNKNOWN_TYPE, "marker.osc") + assert result.status != scn.Status.Ok + assert bool(result) is False + assert len(result.diagnostics) == 1 + diagnostic = result.diagnostics[0] + assert diagnostic.severity == scn.Severity.Error + assert "no_such_type" in diagnostic.message + # The DSL standard defines no `asam.net:` rule ids, so a DSL diagnostic + # cites its section in the message and leaves rule_id empty. + assert "§" in diagnostic.message + assert diagnostic.rule_id == "" + + +def test_a_diagnostic_names_the_file_it_came_from() -> None: + # A checked program spans every file its root imported, so a line number + # alone locates nothing. + result = scn.check_dsl_string(UNKNOWN_TYPE, "marker.osc") + diagnostic = result.diagnostics[0] + assert diagnostic.location.file == "marker.osc" + assert diagnostic.location.line == 2 + + +def test_the_origin_defaults_when_none_is_given() -> None: + result = scn.check_dsl_string(UNKNOWN_TYPE) + assert result.diagnostics[0].location.file == "" + + +def test_without_the_standard_library_its_types_are_gone() -> None: + # Turning the library off is what makes a file checkable in isolation — + # and `length` stops naming anything. + result = scn.check_dsl_string(CLEAN, implicit_standard_library=False) + assert result.status != scn.Status.Ok + assert result.file_count == 1 + assert any("length" in diagnostic.message for diagnostic in result.diagnostics) + + +def test_a_file_is_checked_from_disk(tmp_path) -> None: + source = tmp_path / "marker.osc" + source.write_text(CLEAN, encoding="utf-8") + result = scn.check_dsl_file(source) + assert result.status == scn.Status.Ok + assert result.diagnostics == [] + + +def test_an_unreadable_path_is_host_misuse(tmp_path) -> None: + # InvalidArgument is the Status model's host-misuse code: the path was not + # something we could read at all, which is a different failure from a defect + # in the content. + result = scn.check_dsl_file(tmp_path / "absent.osc") + assert result.status == scn.Status.InvalidArgument + assert len(result.diagnostics) == 1 + assert result.file_count == 0 + + +def test_an_import_is_followed_through_a_search_path(tmp_path) -> None: + library = tmp_path / "lib" + (library / "shapes").mkdir(parents=True) + (library / "shapes" / "basic.osc").write_text( + "struct marker:\n x: length\n", encoding="utf-8" + ) + source = tmp_path / "top.osc" + source.write_text("import shapes.basic\n\nstruct pair:\n a: marker\n", encoding="utf-8") + + without = scn.check_dsl_file(source) + assert without.status != scn.Status.Ok + + result = scn.check_dsl_file(source, search_paths=[library]) + assert result.status == scn.Status.Ok, [d.message for d in result.diagnostics] + # The root, the imported module, and the standard library. + assert result.file_count == without.file_count + 1 + + +def test_the_repr_says_what_the_check_found() -> None: + text = repr(scn.check_dsl_string(CLEAN)) + assert "DslCheck(" in text + assert "status=" in text + assert "file_count=" in text diff --git a/python/tests/test_parity.py b/python/tests/test_parity.py index 7092d7c..a80eb24 100644 --- a/python/tests/test_parity.py +++ b/python/tests/test_parity.py @@ -14,10 +14,11 @@ """The C++ / C / Python parity audit, as a test (p6-s3). -`scripts/parity_audit.py` exits non-zero when a public `scena::Engine` method is -missing from the C ABI or the Python bindings without a recorded reason. Running -it here is what turns the pillar's "parity audit gap-free" exit criterion into -something CI enforces rather than something a human remembers to check. +`scripts/parity_audit.py` exits non-zero when a public `scena::Engine` method or +a frontend entry point is missing from the C ABI or the Python bindings without +a recorded reason. Running it here is what turns the pillar's "parity audit +gap-free" exit criterion into something CI enforces rather than something a +human remembers to check. """ import subprocess @@ -50,10 +51,14 @@ def test_the_audit_emits_a_markdown_table() -> None: ) assert result.returncode == 0, result.stderr lines = result.stdout.splitlines() - assert lines[0].startswith("| `scena::Engine` method |") + assert lines[0].startswith("| `scena::Engine` method or frontend entry point |") assert lines[1].startswith("|---|") assert any("`init`" in line for line in lines) assert any("`step`" in line for line in lines) + # The frontend entry points share the table: p7-s5's DSL check is bound in + # all three surfaces, and this is where that stays visible. + assert any("`dsl::check_file`" in line for line in lines) + assert any("`xml::load_file`" in line for line in lines) def test_the_audit_notices_an_unbound_method(tmp_path) -> None: @@ -66,12 +71,25 @@ def test_the_audit_notices_an_unbound_method(tmp_path) -> None: import shutil sandbox = tmp_path / "repo" - for part in ("core/include/scena", "capi/include/scena", "python/src", "scripts"): + for part in ( + "core/include/scena", + "capi/include/scena", + "python/src", + "scripts", + "frontends/xml/include/scena/xml", + "frontends/dsl/include/scena/dsl", + ): (sandbox / part).mkdir(parents=True, exist_ok=True) shutil.copy(REPO / "scripts" / "parity_audit.py", sandbox / "scripts" / "parity_audit.py") shutil.copy(REPO / "capi" / "include" / "scena" / "capi.h", sandbox / "capi/include/scena/capi.h") shutil.copy(REPO / "python" / "src" / "bindings.cpp", sandbox / "python/src/bindings.cpp") + # The audit reads the frontend headers too; without them it would fail on a + # missing file and this test would pass for the wrong reason. + shutil.copy(REPO / "frontends/xml/include/scena/xml/loader.h", + sandbox / "frontends/xml/include/scena/xml/loader.h") + shutil.copy(REPO / "frontends/dsl/include/scena/dsl/load.h", + sandbox / "frontends/dsl/include/scena/dsl/load.h") header = (REPO / "core" / "include" / "scena" / "engine.h").read_text(encoding="utf-8") doctored = header.replace( From 403720123fa1f5b2a28df7f9b5747b3e77c589ad Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Sun, 2 Aug 2026 03:38:40 -0700 Subject: [PATCH 3/4] docs(dsl): parity audit over the frontend entry points, and the checking docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parity audit only ever looked at scena::Engine methods, so the load and check entry points — the functions a host calls to get a scenario in — could lag a binding unnoticed. It now audits scena::xml and scena::dsl namespace-scope entry points alongside the methods, with the same exclusions-carry-reasons rule; xml::validate_* and dsl::load_* are excluded and say why. Also: the coverage matrix records all four checking surfaces, and scena-check.md gains the C and Python worked examples that c-api.md and python.md link to. Refs #43 --- docs/roadmap/coverage/osc-dsl-coverage.md | 13 ++++ docs/user-guide/c-api.md | 11 +++ docs/user-guide/index.md | 11 +-- docs/user-guide/python.md | 31 ++++++-- docs/user-guide/scena-check.md | 54 +++++++++++++ frontends/dsl/README.md | 14 ++++ scripts/parity_audit.py | 92 +++++++++++++++++++++-- 7 files changed, 206 insertions(+), 20 deletions(-) diff --git a/docs/roadmap/coverage/osc-dsl-coverage.md b/docs/roadmap/coverage/osc-dsl-coverage.md index 32a961b..352ea42 100644 --- a/docs/roadmap/coverage/osc-dsl-coverage.md +++ b/docs/roadmap/coverage/osc-dsl-coverage.md @@ -24,6 +24,19 @@ distribution *selection*, `keep()` constraints requiring search (anything but fixed-value resolution), coverage-driven generation, and external methods. All of these still **Check** cleanly; execution diagnoses them. +## Checking surfaces + +Everything below is reachable from all three of Scena's surfaces. Checking +needs no engine — it is a frontend service, so none of these entry points +construct one. + +| Surface | Check | Sprint(s) | Notes | +|---|---|---|---| +| C++ — `scena::dsl::check_file` / `check_source` | In | p7-s5 | Load and resolve in one call; the `LoadResult` owns the ASTs the `Program` points into (`dsl_import_test.cpp`, `dsl_stdlib_test.cpp`) | +| CLI — `scena-check ` | In | p7-s5 | `-I` search paths, `--no-standard-library`, `--strict`, `--quiet`; exit codes 0 ok / 2 usage / 3 the source did not check / 4 the input could not be read (`scena_check_test.cpp`) | +| C ABI — `scn_check_dsl_file` / `scn_check_dsl_string` | In | p7-s5 | Opaque `scn_dsl_check` handle carrying the diagnostics and the two counts; a failing check still produces one, because that is the case whose findings you want (`c_consumer.c`) | +| Python — `scena.check_dsl_file` / `check_dsl_string` | In | p7-s5 | Returns a `DslCheck` — status, diagnostics, `type_count`, `file_count` (`test_dsl_check.py`, `python/examples/check_dsl.py`) | + ## Language core (§7.2, §7.3) | Feature | Section | Check | Exec | Sprint(s) | Notes | diff --git a/docs/user-guide/c-api.md b/docs/user-guide/c-api.md index 9a073e5..e8b51d2 100644 --- a/docs/user-guide/c-api.md +++ b/docs/user-guide/c-api.md @@ -132,6 +132,17 @@ Signal ids are free-form road-network references, so any name is accepted. A controller phase naming the same signal overwrites it on its next transition, exactly as it overwrites an action's write. +## Checking OpenSCENARIO DSL + +`scn_check_dsl_file` and `scn_check_dsl_string` check a DSL source and its +imports without constructing an engine — checking is a frontend service, which +is why the result is its own `scn_dsl_check` handle rather than an engine's +diagnostic list. Release it with `scn_dsl_check_destroy`, whatever the status: a +failing check still produces one. See +[`scena-check`](scena-check.md#checking-from-c-and-python) for the worked +example, and note that a zero-initialized `scn_dsl_check_options` turns the +standard library *off* — pass `NULL` to get the defaults instead. + ## What is not in C The C ABI carries data, not C++ objects. A `Controller`'s property list, an diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 4d4a7c8..80c0add 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -59,12 +59,12 @@ The user guide grows sprint by sprint along the round-trip-exact trace format, replaying a host-controlled entity, and the golden suite harness. - [`scena-check`](scena-check.md) — the OpenSCENARIO DSL checker: options and - exit codes, the diagnostic format, resolving imports and search paths, and - what "checked clean" does and does not cover. + exit codes, the diagnostic format, resolving imports and search paths, + checking from C and Python, and what "checked clean" does and does not cover. - [Python quickstart](python.md) — installing, loading and running a scenario, building one in memory, observing a run, driving entities from the host with a - `SimulatorGateway` subclass, the GIL and reentrancy policy, and the parity - audit. + `SimulatorGateway` subclass, the GIL and reentrancy policy, checking a DSL + file, and the parity audit. - [Embedding Scena](embedding.md) — the step contract and its phase order, control ownership, the gateway (batching brackets, storyboard observation, controller/visibility/custom-command hooks), the three host-clock patterns @@ -72,7 +72,8 @@ The user guide grows sprint by sprint along the contract. - [The C API](c-api.md) — the stable ABI: versioning and the major check, loading a scenario in one call, entity enumeration, storyboard-element state - by path, host-side signal publication, and the borrowed-string lifetime. + by path, host-side signal publication, checking a DSL source, and the + borrowed-string lifetime. - [Error handling](error-handling.md) — status codes, structured diagnostics, the severity/status invariant, the path grammar, and the C-ABI borrowed-string lifetime. diff --git a/docs/user-guide/python.md b/docs/user-guide/python.md index 976a1d3..34f3e3f 100644 --- a/docs/user-guide/python.md +++ b/docs/user-guide/python.md @@ -144,17 +144,34 @@ One thing deliberately does not reach Python: **`road_query()`**. `IRoadQuery` i queried per entity per step, and routing it through the interpreter would put Python on the runtime's hot path. Implement the C++ interface for that. +## Check an OpenSCENARIO DSL file + +```python +result = scn.check_dsl_file("overtake.osc", search_paths=["lib"]) +print(result.status, result.type_count, result.file_count) +for diagnostic in result.diagnostics: + print(diagnostic.location.file, diagnostic.location.line, diagnostic.message) +``` + +Checking is not running: DSL execution is P8, so a file that checks clean is one +the frontend understood. `check_dsl_string(source, origin)` does the same for a +source in memory. Both return a `DslCheck`, which is falsy unless the status is +`Ok`. `python/examples/check_dsl.py` is the runnable version, and +[`scena-check`](scena-check.md) documents the options and what "checked clean" +covers. + ## Parity with C++ and C -`scripts/parity_audit.py` extracts the public `scena::Engine` methods and checks -each against the C ABI and the Python bindings. A method missing from either -must be listed in the script's `EXCLUSIONS` with a reason; anything else is a -gap and the script exits non-zero. `python/tests/test_parity.py` runs it, and CI -runs it again on every platform — including a test that doctors a header to -prove the audit still detects a real gap. +`scripts/parity_audit.py` extracts the public `scena::Engine` methods and the +frontend entry points (`scena::xml`, `scena::dsl`) and checks each against the C +ABI and the Python bindings. Anything missing from either must be listed in the +script's exclusion tables with a reason; anything else is a gap and the script +exits non-zero. `python/tests/test_parity.py` runs it, and CI runs it again on +every platform — including a test that doctors a header to prove the audit still +detects a real gap. ```sh -python scripts/parity_audit.py # ok/GAP per method +python scripts/parity_audit.py # ok/GAP per entry point python scripts/parity_audit.py --markdown # the table, for docs or release notes ``` diff --git a/docs/user-guide/scena-check.md b/docs/user-guide/scena-check.md index 582bcb3..1c1941e 100644 --- a/docs/user-guide/scena-check.md +++ b/docs/user-guide/scena-check.md @@ -111,6 +111,60 @@ scenario overtake: `--no-standard-library` turns even the implicit part off, which is useful when checking a file that is meant to stand alone. +## Checking from C and Python + +The CLI is a thin consumer of the same entry point the bindings expose, so an +editor plugin or a build script can have the findings as objects instead of +parsing text. Checking needs no engine — it is a frontend service. + +In Python, one call returns a `DslCheck`: + +```python +import scena as scn + +result = scn.check_dsl_file("overtake.osc", search_paths=["lib"]) +if not result: + for diagnostic in result.diagnostics: + where = diagnostic.location + print(f"{where.file}:{where.line}:{where.column}: {diagnostic.message}") +else: + print(f"ok, {result.type_count} types across {result.file_count} files") +``` + +`DslCheck` carries `status`, `diagnostics`, `type_count` and `file_count`, and +is falsy unless the status is `Ok`. `check_dsl_string(source, origin)` checks a +source in memory; `origin` names it in diagnostics and anchors its relative +imports, and need not exist on disk. Both take `search_paths` and +`implicit_standard_library`, the two `LoadOptions` fields. The XML loaders +return `(status, scenario)` because the scenario is the payload; a check has no +payload — the findings are the result — so it returns one named object. + +In C, the result is an opaque handle you destroy when done: + +```c +scn_dsl_check_options options = {0}; +options.implicit_standard_library = 1; /* the zero struct turns it OFF */ + +scn_dsl_check* check = NULL; +scn_status status = scn_check_dsl_file("overtake.osc", &options, &check); + +size_t count = 0; +scn_dsl_check_diagnostic_count(check, &count); +for (size_t i = 0; i < count; ++i) { + scn_diagnostic diagnostic; + scn_dsl_check_diagnostic_at(check, i, &diagnostic); + fprintf(stderr, "%s:%d:%d: %s\n", diagnostic.file, diagnostic.line, + diagnostic.column, diagnostic.message); +} +scn_dsl_check_destroy(check); +``` + +A failing check still produces a handle — that is exactly the case whose +diagnostics you want — so destroy it whatever the status. The strings a +diagnostic borrows stay valid until `scn_dsl_check_destroy`; unlike the engine's +diagnostics, nothing else invalidates them, because a check result never +changes. Only a NULL argument returns without a handle. + ## What "checked clean" covers The checker resolves names, types every expression, and validates constraints diff --git a/frontends/dsl/README.md b/frontends/dsl/README.md index 0aaceae..7ed6b8c 100644 --- a/frontends/dsl/README.md +++ b/frontends/dsl/README.md @@ -47,6 +47,12 @@ expected. It also keeps the dependency list unchanged. | `tests/dsl_types_test.cpp` | §7.3 type rules, units, inheritance, namespaces | | `tests/dsl_expression_test.cpp` | §7.4 typing, conversion rules, constant folding | | `tests/dsl_constraint_test.cpp` | §7.3.11 constraints and §7.5 coverage | +| `include/scena/dsl/load.h` | `load_*` / `check_*` — imports (§7.7.5) and the one-call check | +| `src/load.cpp` | import resolution, both reference forms, import-once | +| `include/scena/dsl/stdlib.h` | the bundled `osc.standard` library (ADR-0029) | +| `src/stdlib.cpp` | its source, as chunked raw literals | +| `tests/dsl_import_test.cpp` | §7.7.5 imports, search paths, diagnostics carrying their file | +| `tests/dsl_stdlib_test.cpp` | the §8 library, pinned declaration by declaration | ## Lexing notes @@ -195,3 +201,11 @@ expected. It also keeps the dependency list unchanged. - **`check_source` / `check_file` are the entry points.** Loading and resolving in one call, with imports followed; the CLI and the bindings sit on them. The `LoadResult` owns the ASTs a `Program` points into, so it must outlive it. +- **All three surfaces reach them.** `scena-check`, the C ABI + (`scn_check_dsl_file` / `scn_check_dsl_string`, results in an opaque + `scn_dsl_check`) and Python (`scena.check_dsl_file` / `check_dsl_string`, + returning a `DslCheck`). None of them constructs an engine — checking is a + frontend service, and that is why the C result is its own handle rather than + an engine's diagnostic list. `scripts/parity_audit.py` now audits these entry + points alongside the `Engine` methods, so a frontend function added to one + surface and forgotten in the others is a CI failure. diff --git a/scripts/parity_audit.py b/scripts/parity_audit.py index a3ba3a3..be246c1 100644 --- a/scripts/parity_audit.py +++ b/scripts/parity_audit.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Audits the C++ / C / Python parity of the engine surface. +"""Audits the C++ / C / Python parity of the engine and frontend surfaces. Scena ships three ways to drive the same engine, and the pillar's exit criterion is that none of them silently lags the others. This script extracts the public @@ -24,10 +24,16 @@ - the Python bindings, by looking for its `.def(...)` / `.def_prop_ro(...)` in `python/src/bindings.cpp`. -A method that is absent from a binding must appear in `EXCLUSIONS` below with a -reason. Anything else is a **gap** and the script exits non-zero — that is what -makes the audit a test rather than a report (`python/tests/test_parity.py` runs -it). +It then does the same for the **frontend entry points** — the free functions a +host calls to get a scenario in, from `scena::xml` and `scena::dsl`. They are +not `Engine` methods, but they are just as much part of the surface a binding +can silently lag: p7-s5 added DSL checking to all three, and nothing would have +noticed if it had reached only two. + +A method or entry point that is absent from a binding must appear in the +matching exclusion table below with a reason. Anything else is a **gap** and the +script exits non-zero — that is what makes the audit a test rather than a report +(`python/tests/test_parity.py` runs it). The extraction is deliberately textual. A real one would need a C++ parser and a build of the bindings; the point here is to notice when someone adds a method and @@ -50,6 +56,8 @@ ENGINE_HEADER = REPO / "core" / "include" / "scena" / "engine.h" CAPI_HEADER = REPO / "capi" / "include" / "scena" / "capi.h" BINDINGS = REPO / "python" / "src" / "bindings.cpp" +XML_LOADER_HEADER = REPO / "frontends" / "xml" / "include" / "scena" / "xml" / "loader.h" +DSL_LOAD_HEADER = REPO / "frontends" / "dsl" / "include" / "scena" / "dsl" / "load.h" #: C++ Engine methods that deliberately do not reach one or both bindings, with #: the reason. Keeping the reason here rather than in a comment is what lets the @@ -96,6 +104,43 @@ "default_lane_width": "default_lane_width", } +#: The frontend headers whose namespace-scope entry points are audited, and the +#: namespace each one lives in (used only to name the row). +FRONTEND_HEADERS: dict[str, Path] = { + "xml": XML_LOADER_HEADER, + "dsl": DSL_LOAD_HEADER, +} + +#: `::` -> the C entry point that implements it. The names +#: differ more than the engine's do, because C has to say which frontend. +FRONTEND_C_ALIASES: dict[str, str] = { + "xml::load_file": "scn_engine_load_xml_file", + "xml::load_string": "scn_engine_load_xml_string", + "dsl::check_file": "scn_check_dsl_file", + "dsl::check_source": "scn_check_dsl_string", +} + +#: `::` -> the Python name. +FRONTEND_PY_ALIASES: dict[str, str] = { + "xml::load_file": "load_file", + "xml::load_string": "load_string", + "dsl::check_file": "check_dsl_file", + "dsl::check_source": "check_dsl_string", +} + +#: Frontend entry points that deliberately do not reach one or both bindings. +FRONTEND_EXCLUSIONS: dict[str, str] = { + # Validation without a scenario is a C++ diagnostic-only path; the bindings + # expose loading, which validates on the way through. + "xml::validate_file": "loading validates; a validate-only pass has no binding consumer", + "xml::validate_string": "loading validates; a validate-only pass has no binding consumer", + # load_* stops after parsing and import resolution and hands back ASTs that + # only C++ can walk. check_* is the same work plus resolution, and is what + # a host actually wants. + "dsl::load_file": "the lower half of check_file; its result is an AST no binding can carry", + "dsl::load_source": "the lower half of check_source; same reason", +} + @dataclass class Row: @@ -129,6 +174,21 @@ def public_engine_methods(text: str) -> list[str]: return names +def frontend_entry_points(text: str) -> list[str]: + """Namespace-scope `Status (` declarations in a frontend header. + + Same deliberately textual extraction as the engine side: a member function + is indented, so anchoring at column 0 is what separates an entry point from + a method on a class the header also declares. + """ + names: list[str] = [] + for match in re.finditer(r"^(?:\[\[nodiscard\]\]\s+)?Status\s+(\w+)\s*\(", text, re.M): + name = match.group(1) + if name not in names: + names.append(name) + return names + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--markdown", action="store_true", help="emit the table as Markdown") @@ -151,10 +211,24 @@ def main() -> int: ) ) + for name_space, header in FRONTEND_HEADERS.items(): + for function in frontend_entry_points(header.read_text(encoding="utf-8")): + qualified = f"{name_space}::{function}" + c_symbol = FRONTEND_C_ALIASES.get(qualified, "") + py_name = FRONTEND_PY_ALIASES.get(qualified, "") + rows.append( + Row( + method=qualified, + in_c=bool(c_symbol) and c_symbol in capi_text, + in_python=bool(py_name) and f'"{py_name}"' in bindings_text, + excluded=FRONTEND_EXCLUSIONS.get(qualified), + ) + ) + gaps = [row for row in rows if not row.ok] if args.markdown: - print("| `scena::Engine` method | C ABI | Python | note |") + print("| `scena::Engine` method or frontend entry point | C ABI | Python | note |") print("|---|---|---|---|") for row in rows: note = row.excluded or "" @@ -172,10 +246,12 @@ def main() -> int: f"C:{'y' if row.in_c else 'n'} Py:{'y' if row.in_python else 'n'}{note}" ) - print(f"\n{len(rows)} methods, {len(gaps)} gap(s)", file=sys.stderr) + print(f"\n{len(rows)} entry points, {len(gaps)} gap(s)", file=sys.stderr) for row in gaps: + # A frontend row already carries its namespace; an Engine method does not. + qualified = row.method if "::" in row.method else f"Engine::{row.method}" print( - f"GAP: Engine::{row.method} is missing from " + f"GAP: {qualified} is missing from " f"{'the C ABI' if not row.in_c else ''}" f"{' and ' if not row.in_c and not row.in_python else ''}" f"{'the Python bindings' if not row.in_python else ''}" From 41a5d9d9de09c7a210d6155e78c2b92ac14c2369 Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Sun, 2 Aug 2026 03:44:07 -0700 Subject: [PATCH 4/4] fix(build): position-independent code for the DSL frontend scena-frontend-dsl is a static library, and this PR is the first thing to link it into a shared object (scena-capi and the Python extension). GNU ld rejects that outright without -fPIC. The XML frontend and the core already carry POSITION_INDEPENDENT_CODE for exactly this reason; macOS never noticed because its toolchain compiles PIC by default. Refs #43 --- frontends/dsl/CMakeLists.txt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontends/dsl/CMakeLists.txt b/frontends/dsl/CMakeLists.txt index c819647..1d983b8 100644 --- a/frontends/dsl/CMakeLists.txt +++ b/frontends/dsl/CMakeLists.txt @@ -22,7 +22,15 @@ target_include_directories(scena-frontend-dsl PUBLIC ) target_link_libraries(scena-frontend-dsl PUBLIC scena::core) target_compile_features(scena-frontend-dsl PUBLIC cxx_std_20) -set_target_properties(scena-frontend-dsl PROPERTIES CXX_EXTENSIONS OFF) +# POSITION_INDEPENDENT_CODE because this static library is linked into the +# shared scena-capi and into the Python extension module. Without it GNU ld +# rejects the link outright ("relocation ... can not be used when making a +# shared object"); the XML frontend and the core carry the same property for +# the same reason. +set_target_properties(scena-frontend-dsl PROPERTIES + CXX_EXTENSIONS OFF + POSITION_INDEPENDENT_CODE ON +) scn_set_warnings(scena-frontend-dsl) scn_set_fp_strictness(scena-frontend-dsl)