diff options
| author | Leitwolf11 | 2026-10-25 13:57:03 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-10-25 13:57:03 +0300 |
| commit | 45f8c539e86099cb80ecf29b0bf2b3340e141001 (patch) | |
| tree | cf7f28b76fb2994a3750ecdd127b5738f4d56ad1 | |
| parent | d2e3e074bb2e1b8ffd9fc990d0b87667ea602f58 (diff) | |
| download | catch3-45f8c539e86099cb80ecf29b0bf2b3340e141001.tar.gz | |
Read and write JSON with yyjson and JUnit XML with pugixml
The runner's hand-written JSON parser and writer, and its XML escaping,
are replaced by two established MIT libraries, vendored in third_party/:
yyjson 0.13.0 and pugixml 1.16. JsonValue and the JUnit model stay as they
are.
Both are compiled into Catch3's own translation units, so a project that
links its own yyjson or pugixml is not affected:
- src/Progmasoft/Runner/Yyjson.inl includes yyjson.c into Json.cpp with
yyjson_api defined as static, so no yyjson symbol leaves that file.
- src/Progmasoft/PugiXml.inl includes pugixml header-only into
XmlWriter.cpp with its namespace renamed to Progmasoft_Catch3_Pugi.
- Both are read as system headers, outside the warnings Catch3 builds
with; they are .inl files, which the header install rules leave out.
JSON keeps its behaviour: strings are bytes and come back as written
(invalid UTF-8 is allowed both ways), non-finite numbers are written as
null, documents nested deeper than 256 levels are refused while they are
converted, and whole numbers are written as integers. Fractions are now
written as the shortest text that reads back as the same double.
JUnit XML stays equivalent; pugixml writes ' and > in attributes as they
are, U+FFFD as the character itself and empty elements as <x />. The
tests follow.
third_party/{yyjson,pugixml} each have BUILD.bazel, CMakeLists.txt,
meson.build, xmake.lua and premake5.lua, which only provide the include
directory to the library; CMake links them through BUILD_INTERFACE.
tools/scripts/updateThirdParty.py pins the version and sha256 of each
archive; --check, now a step of the header and license workflow, fails
when a vendored file differs from its archive, and --update writes them.
Renovate proposes new versions through a regex manager on that table.
The amalgamation now has a third file, catch_amalgamated_third_party.hpp,
with both wrappers and the libraries inlined; catch_amalgamated.cpp
includes it once. The documents and the release notes say so.
39 files changed, 71777 insertions, 1412 deletions
diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 85ecd13a..875781be 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -38,13 +38,33 @@ ignorePaths: ["third_party/**", "**/node_modules/**"], - // What Dependabot watched here, and nothing more for now. - enabledManagers: ["github-actions", "bazel-module", "pip_requirements"], + // What Dependabot watched here, and the libraries vendored in third_party/. + enabledManagers: [ + "github-actions", + "bazel-module", + "pip_requirements", + "custom.regex", + ], + + // third_party/ holds copies of release archives, which + // tools/scripts/updateThirdParty.py pins by version and sha256. Renovate + // changes the version; the pull request then needs the new digest and + // --update, which the third_party check of CI asks for. + customManagers: [ + { + customType: "regex", + managerFilePatterns: ["/^tools/scripts/updateThirdParty\\.py$/"], + matchStrings: [ + "# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)( extractVersion=(?<extractVersion>\\S+))?\\s+\"version\": \"(?<currentValue>[^\"]+)\"", + ], + }, + ], // One pull request for each kind of dependency. packageRules: [ { matchManagers: ["github-actions"], groupName: "GitHub Actions" }, { matchManagers: ["bazel-module"], groupName: "Bazel modules" }, { matchManagers: ["pip_requirements"], groupName: "Python tools of CI" }, + { matchManagers: ["custom.regex"], groupName: "Vendored libraries" }, ], } diff --git a/.github/workflows/validate-header-guards.yml b/.github/workflows/validate-header-guards.yml index c5f8a35d..5641ce68 100644 --- a/.github/workflows/validate-header-guards.yml +++ b/.github/workflows/validate-header-guards.yml @@ -39,3 +39,8 @@ jobs: - name: Check that all source files have the correct license header run: | ./tools/scripts/checkLicense.py + + # third_party/ must be the pinned release archives, byte for byte. + - name: Check that third_party matches its pinned archives + run: | + ./tools/scripts/updateThirdParty.py --check diff --git a/CMakeLists.txt b/CMakeLists.txt index 2bdb177b..65b1c1e0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -77,6 +77,10 @@ set(CATCH_DIR ${CMAKE_CURRENT_SOURCE_DIR}) set(SOURCES_DIR ${CATCH_DIR}/src/Progmasoft/Catch3) set(SELF_TEST_DIR ${CATCH_DIR}/tests/SelfTest) +# yyjson and pugixml, compiled into the library; nothing of them is installed. +add_subdirectory(third_party/yyjson) +add_subdirectory(third_party/pugixml) + # We need to bring-in the variables defined there to this scope add_subdirectory(src) diff --git a/docs/migrate-v2-to-v3.md b/docs/migrate-v2-to-v3.md index 91f65ac8..bd030523 100644 --- a/docs/migrate-v2-to-v3.md +++ b/docs/migrate-v2-to-v3.md @@ -37,9 +37,10 @@ To migrate to v3, there are two basic approaches to do so. 1. Use `catch_amalgamated.hpp` and `catch_amalgamated.cpp`. 2. Build Catch3 as a proper (static) library, and move to piecewise headers -Doing 1 means downloading the [amalgamated header](../extras/catch_amalgamated.hpp) -and the [amalgamated sources](../extras/catch_amalgamated.cpp) from `extras`, -dropping them into your test project, and rewriting your includes from +Doing 1 means downloading the [amalgamated header](../extras/catch_amalgamated.hpp), +the [amalgamated sources](../extras/catch_amalgamated.cpp) and +[the JSON and XML libraries they include](../extras/catch_amalgamated_third_party.hpp) +from `extras`, dropping them into your test project, and rewriting your includes from `<catch2/catch.hpp>` to `"catch_amalgamated.hpp"` (or something similar, based on how you set up your paths). diff --git a/docs/release-notes.md b/docs/release-notes.md index 3dd4ca66..aef49cf0 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -33,6 +33,13 @@ line and reporters with [a runner of its own](progmasoft-runner.md#top): * Reports are console, JUnit XML, JSON and a Chrome trace; `--list=json` is a versioned discovery format. * Every test gets a temporary directory of its own. +* JSON is read and written with [yyjson](https://github.com/ibireme/yyjson) + and JUnit XML is written with [pugixml](https://github.com/zeux/pugixml), + both MIT and vendored in `third_party/`. They are compiled into Catch3's + own translation units with internal linkage or a renamed namespace, so a + project that links its own copy of either library is not affected. The + amalgamated sources include them from a third file, + `extras/catch_amalgamated_third_party.hpp`. ### Breaking changes diff --git a/extras/catch_amalgamated.cpp b/extras/catch_amalgamated.cpp index 0f336b53..a9cdb04b 100644 --- a/extras/catch_amalgamated.cpp +++ b/extras/catch_amalgamated.cpp @@ -7,7 +7,7 @@ // SPDX-License-Identifier: BSL-1.0 // Catch3 v0.3.0, on the Catch2 v3.16.1 engine -// Generated: 2026-10-25 02:37:10.065190 +// Generated: 2026-10-25 13:52:54.556111 // ---------------------------------------------------------- // This file is an amalgamation of multiple different files. // You probably shouldn't edit it directly. @@ -658,9 +658,6 @@ namespace Progmasoft::Catch3::Runner private: void - Write(std::string &output, int indent, int depth) const; - - void Destroy() noexcept; void @@ -8976,6 +8973,8 @@ namespace Progmasoft::Catch3 // Copyright (c) 2026 Progmasoft. // SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 +#include "catch_amalgamated_third_party.hpp" + #include <ostream> #include <sstream> #include <string_view> @@ -9055,63 +9054,50 @@ namespace Progmasoft::Catch3 (value >= 0x10000 && value <= 0x10FFFF); } - void - WriteEscaped(std::string_view value, std::ostream &output, bool attribute) + /// The text as XML can hold it: every byte that is not part of a + /// well-formed character XML allows becomes U+FFFD, replaced and not + /// dropped, so the reader sees that something was there. pugixml + /// escapes the rest. + std::string + XmlText(std::string_view value) { + std::string result; + result.reserve(value.size()); for (std::size_t index = 0; index < value.size();) { const DecodedCharacter character = DecodeUtf8(value, index); if (character.length == 0 || !IsXmlCharacter(character.value)) { - // A character XML cannot hold is replaced, not dropped, - // so the reader sees that something was there. - output << "�"; + result += "\xEF\xBF\xBD"; ++index; continue; } - if (character.value < 0x80) - { - switch (static_cast<char>(character.value)) - { - case '&': - output << "&"; - break; - case '<': - output << "<"; - break; - case '>': - output << ">"; - break; - case '"': - output << (attribute ? """ : "\""); - break; - case '\'': - output << (attribute ? "'" : "'"); - break; - case '\n': - output << (attribute ? " " : "\n"); - break; - default: - output.put(static_cast<char>(character.value)); - } - } - else - { - output.write(value.data() + index, - static_cast<std::streamsize>(character.length)); - } + result.append(value.data() + index, character.length); index += character.length; } + return result; } void - WriteAttribute(std::ostream &output, - std::string_view name, + AddAttribute(pugi::xml_node node, + const char *name, + std::string_view value) + { + node.append_attribute(name).set_value(XmlText(value).c_str()); + } + + void + AddText(pugi::xml_node node, std::string_view value) + { + node.text().set(XmlText(value).c_str()); + } + + void + AddTextElement(pugi::xml_node parent, + const char *name, std::string_view value) { - output << ' ' << name << "=\""; - WriteEscaped(value, output, true); - output << '"'; + AddText(parent.append_child(name), value); } std::string @@ -9125,27 +9111,6 @@ namespace Progmasoft::Catch3 "." + fraction; } - void - WriteIndent(std::ostream &output, unsigned level) - { - for (unsigned index = 0; index < level; ++index) - { - output << " "; - } - } - - void - WriteTextElement(std::ostream &output, - unsigned indent, - std::string_view name, - std::string_view value) - { - WriteIndent(output, indent); - output << '<' << name << '>'; - WriteEscaped(value, output, false); - output << "</" << name << ">\n"; - } - std::string Output(const Attempt &attempt, bool errors) { @@ -9159,27 +9124,22 @@ namespace Progmasoft::Catch3 /// An earlier attempt: `<rerunFailure>` before a final failure, /// `<flakyFailure>` before a final pass. void - WriteEarlierAttempt(std::ostream &output, - const Attempt &attempt, - std::string_view element) - { - WriteIndent(output, 3); - output << '<' << element; - WriteAttribute(output, "message", SummarizeFailure(attempt)); - WriteAttribute(output, "type", CategoryName(attempt.category)); - output << ">\n"; - WriteTextElement(output, 4, "stackTrace", DescribeFailure(attempt)); + AddEarlierAttempt(pugi::xml_node testCase, + const Attempt &attempt, + const char *element) + { + pugi::xml_node node = testCase.append_child(element); + AddAttribute(node, "message", SummarizeFailure(attempt)); + AddAttribute(node, "type", CategoryName(attempt.category)); + AddTextElement(node, "stackTrace", DescribeFailure(attempt)); if (!attempt.standardOutput.empty()) { - WriteTextElement( - output, 4, "system-out", Output(attempt, false)); + AddTextElement(node, "system-out", Output(attempt, false)); } if (!Output(attempt, true).empty()) { - WriteTextElement(output, 4, "system-err", Output(attempt, true)); + AddTextElement(node, "system-err", Output(attempt, true)); } - WriteIndent(output, 3); - output << "</" << element << ">\n"; } std::string @@ -9200,23 +9160,21 @@ namespace Progmasoft::Catch3 } void - WriteTestCase(std::ostream &output, - const TestResult &test, - std::string_view suiteName) - { - WriteIndent(output, 2); - output << "<testcase"; - WriteAttribute(output, - "classname", - test.className.empty() ? suiteName : test.className); - WriteAttribute(output, "name", test.name); - WriteAttribute(output, "time", JUnitSeconds(test.DurationMicroseconds())); + AddTestCase(pugi::xml_node suite, + const TestResult &test, + std::string_view suiteName) + { + pugi::xml_node node = suite.append_child("testcase"); + AddAttribute(node, + "classname", + test.className.empty() ? suiteName : test.className); + AddAttribute(node, "name", test.name); + AddAttribute(node, "time", JUnitSeconds(test.DurationMicroseconds())); if (!test.file.empty()) { - WriteAttribute(output, "file", test.file); - WriteAttribute(output, "line", std::to_string(test.line)); + AddAttribute(node, "file", test.file); + AddAttribute(node, "line", std::to_string(test.line)); } - output << ">\n"; const Attempt *last = test.attempts.empty() ? nullptr : &test.attempts.back(); @@ -9226,12 +9184,10 @@ namespace Progmasoft::Catch3 const bool error = test.outcome != Outcome::kFailed || last->category == FailureCategory::kInfrastructure; - const std::string_view element = error ? "error" : "failure"; - WriteIndent(output, 3); - output << '<' << element; - WriteAttribute(output, "message", SummarizeFailure(*last)); - WriteAttribute(output, "type", CategoryName(test.category)); - output << '>'; + pugi::xml_node failure = + node.append_child(error ? "error" : "failure"); + AddAttribute(failure, "message", SummarizeFailure(*last)); + AddAttribute(failure, "type", CategoryName(test.category)); std::string details = DescribeFailure(*last); for (const auto &finding : test.analysis) { @@ -9241,18 +9197,15 @@ namespace Progmasoft::Catch3 { details += "replay: " + test.replayCommand + "\n"; } - WriteEscaped(details, output, false); - output << "</" << element << ">\n"; + AddText(failure, details); } else if (test.outcome == Outcome::kSkipped || test.outcome == Outcome::kBlocked || test.outcome == Outcome::kNotRun || test.outcome == Outcome::kUpToDate) { - WriteIndent(output, 3); - output << "<skipped"; - WriteAttribute(output, "message", SkipReason(test)); - output << "/>\n"; + AddAttribute( + node.append_child("skipped"), "message", SkipReason(test)); } // Every earlier failed attempt, so retries stay visible. for (std::size_t index = 0; index + 1 < test.attempts.size(); @@ -9263,20 +9216,36 @@ namespace Progmasoft::Catch3 { continue; } - WriteEarlierAttempt(output, - attempt, - failedRun ? "rerunFailure" : "flakyFailure"); + AddEarlierAttempt(node, + attempt, + failedRun ? "rerunFailure" : "flakyFailure"); } if (last != nullptr && !last->standardOutput.empty()) { - WriteTextElement(output, 3, "system-out", Output(*last, false)); + AddTextElement(node, "system-out", Output(*last, false)); } if (last != nullptr && !Output(*last, true).empty()) { - WriteTextElement(output, 3, "system-err", Output(*last, true)); + AddTextElement(node, "system-err", Output(*last, true)); } - WriteIndent(output, 2); - output << "</testcase>\n"; + } + + void + AddCounts(pugi::xml_node node, + const RunResult &run, + const RunSummary &summary) + { + AddAttribute(node, "name", run.binary); + AddAttribute(node, "tests", std::to_string(summary.tests)); + AddAttribute(node, "failures", std::to_string(summary.failed)); + AddAttribute(node, + "errors", + std::to_string(summary.crashed + summary.timedOut)); + AddAttribute(node, + "skipped", + std::to_string(summary.skipped + summary.blocked + + summary.notRun + summary.upToDate)); + AddAttribute(node, "time", JUnitSeconds(run.wallMicroseconds)); } } // namespace @@ -9284,38 +9253,21 @@ namespace Progmasoft::Catch3 JUnitXmlWriter::Write(const RunResult &run, std::ostream &output) { const RunSummary summary = Summarize(run); - const std::size_t errors = summary.crashed + summary.timedOut; - const std::size_t failures = summary.failed; - const std::size_t skipped = - summary.skipped + summary.blocked + summary.notRun + - summary.upToDate; - const std::string time = JUnitSeconds(run.wallMicroseconds); - output << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<testsuites"; - WriteAttribute(output, "name", run.binary); - WriteAttribute(output, "tests", std::to_string(summary.tests)); - WriteAttribute(output, "failures", std::to_string(failures)); - WriteAttribute(output, "errors", std::to_string(errors)); - WriteAttribute(output, "skipped", std::to_string(skipped)); - WriteAttribute(output, "time", time); - output << ">\n"; - WriteIndent(output, 1); - output << "<testsuite"; - WriteAttribute(output, "name", run.binary); - WriteAttribute(output, "tests", std::to_string(summary.tests)); - WriteAttribute(output, "failures", std::to_string(failures)); - WriteAttribute(output, "errors", std::to_string(errors)); - WriteAttribute(output, "skipped", std::to_string(skipped)); - WriteAttribute(output, "time", time); - output << ">\n"; - WriteIndent(output, 2); - output << "<properties>\n"; - const auto property = [&](std::string_view name, - const std::string &value) { - WriteIndent(output, 3); - output << "<property"; - WriteAttribute(output, "name", name); - WriteAttribute(output, "value", value); - output << "/>\n"; + pugi::xml_document document; + pugi::xml_node declaration = + document.append_child(pugi::node_declaration); + declaration.append_attribute("version").set_value("1.0"); + declaration.append_attribute("encoding").set_value("UTF-8"); + + pugi::xml_node suites = document.append_child("testsuites"); + AddCounts(suites, run, summary); + pugi::xml_node suite = suites.append_child("testsuite"); + AddCounts(suite, run, summary); + pugi::xml_node properties = suite.append_child("properties"); + const auto property = [&](const char *name, const std::string &value) { + pugi::xml_node node = properties.append_child("property"); + AddAttribute(node, "name", name); + AddAttribute(node, "value", value); }; property("random-seed", std::to_string(run.seed)); property("workers", std::to_string(run.workers)); @@ -9327,14 +9279,12 @@ namespace Progmasoft::Catch3 } property("flaky", std::to_string(summary.flaky)); property("quarantined", std::to_string(summary.quarantined)); - WriteIndent(output, 2); - output << "</properties>\n"; for (const auto &test : run.tests) { - WriteTestCase(output, test, run.binary); + AddTestCase(suite, test, run.binary); } - WriteIndent(output, 1); - output << "</testsuite>\n</testsuites>\n"; + + document.save(output, " ", pugi::format_indent, pugi::encoding_utf8); return static_cast<bool>(output); } @@ -12180,12 +12130,9 @@ namespace Progmasoft::Catch3::Runner // Copyright (c) 2026 Progmasoft. // SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 -#include <clocale> + #include <cmath> -#include <cstdio> #include <cstdlib> -#include <limits> -#include <optional> namespace Progmasoft::Catch3::Runner { @@ -12223,481 +12170,160 @@ namespace Progmasoft::Catch3::Runner } /// Documents nested deeper than this are refused, so a hostile or - /// broken file cannot exhaust the stack of the parser. + /// broken file cannot exhaust the stack while it is converted. constexpr int kMaximumDepth = 256; - void - AppendUtf8(std::string &output, std::uint32_t codePoint) - { - if (codePoint < 0x80) - { - output.push_back(static_cast<char>(codePoint)); - } - else if (codePoint < 0x800) - { - output.push_back(static_cast<char>(0xC0 | (codePoint >> 6))); - output.push_back(static_cast<char>(0x80 | (codePoint & 0x3F))); - } - else if (codePoint < 0x10000) - { - output.push_back(static_cast<char>(0xE0 | (codePoint >> 12))); - output.push_back( - static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F))); - output.push_back(static_cast<char>(0x80 | (codePoint & 0x3F))); - } - else - { - output.push_back(static_cast<char>(0xF0 | (codePoint >> 18))); - output.push_back( - static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F))); - output.push_back( - static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F))); - output.push_back(static_cast<char>(0x80 | (codePoint & 0x3F))); - } - } + /// Strings are bytes: test names and output need not be UTF-8, and + /// they come back as they were written. + constexpr yyjson_read_flag kReadFlags = + YYJSON_READ_ALLOW_INVALID_UNICODE; - /// Reads a JSON number exactly. The grammar is checked here; strtod - /// does the rounding, with the point replaced by the decimal - /// separator of the current locale, which a test may have changed. - std::optional<double> - ToDouble(std::string_view digits) - { - std::size_t index = 0; - const auto skipDigits = [&] { - const std::size_t start = index; - while (index < digits.size() && digits[index] >= '0' && - digits[index] <= '9') - { - ++index; - } - return index > start; - }; - if (index < digits.size() && digits[index] == '-') - { - ++index; - } - if (!skipDigits()) - { - return std::nullopt; - } - std::size_t point = std::string_view::npos; - if (index < digits.size() && digits[index] == '.') - { - point = index++; - if (!skipDigits()) - { - return std::nullopt; - } - } - if (index < digits.size() && - (digits[index] == 'e' || digits |