diff options
| author | Leitwolf | 2026-10-24 19:03:40 +0300 |
|---|---|---|
| committer | GitHub | 2026-10-24 19:03:40 +0300 |
| commit | 4f1c759cffcb14bd6511e1d7c5fbace682e24f2d (patch) | |
| tree | 415fe5d48a5b48650bba3747beaf245d727c4f61 /extras | |
| parent | 83c6fc3ac13abe83fa39f7b5f8559ccaf4412233 (diff) | |
| parent | 622188f9c08e4af35941c146979883ed4882410e (diff) | |
| download | catch3-4f1c759cffcb14bd6511e1d7c5fbace682e24f2d.tar.gz | |
Merge pull request #15 from Progmasoft/feature/catch3-runner
Replace Catch2's runner with the Catch3 runner
Diffstat (limited to 'extras')
| -rw-r--r-- | extras/Catch.cmake | 20 | ||||
| -rw-r--r-- | extras/CatchAddTests.cmake | 69 | ||||
| -rw-r--r-- | extras/CatchShardTests.cmake | 2 | ||||
| -rw-r--r-- | extras/CatchShardTestsImpl.cmake | 17 | ||||
| -rw-r--r-- | extras/catch_amalgamated.cpp | 17937 | ||||
| -rw-r--r-- | extras/catch_amalgamated.hpp | 2750 |
6 files changed, 12128 insertions, 8667 deletions
diff --git a/extras/Catch.cmake b/extras/Catch.cmake index 14ddf228..1b068de6 100644 --- a/extras/Catch.cmake +++ b/extras/Catch.cmake @@ -44,7 +44,8 @@ same as the Catch name; see also ``TEST_PREFIX`` and ``TEST_SUFFIX``. ``catch_discover_tests`` sets up a post-build command on the test executable that generates the list of tests by parsing the output from running the test - with the ``--list-tests --reporter json`` argument. This ensures that the full + with the ``--list=json`` argument, the discovery protocol of the Catch3 runner + (``"format": "catch3-tests"``, version 1). This ensures that the full list of tests is obtained. Since test discovery occurs at build time, it is not necessary to re-run CMake when the list of tests changes. However, it requires that :prop_tgt:`CROSSCOMPILING_EMULATOR` is properly set @@ -103,25 +104,24 @@ same as the Catch name; see also ``TEST_PREFIX`` and ``TEST_SUFFIX``. Note that this variable is only available in CTest. ``REPORTER reporter`` - Use the specified reporter when running the test case. The reporter will - be passed to the Catch executable as ``--reporter reporter``. + Write a report of each test: ``junit`` passes ``--junit=<file>`` and + ``json`` passes ``--json=<file>`` to the test executable; ``console`` + writes no file. ``junit`` and ``json`` need ``OUTPUT_DIR``. ``OUTPUT_DIR dir`` - If specified, the parameter is passed along as - ``--out dir/<test_name>`` to Catch executable. The actual file name is the - same as the test name. This should be used instead of - ``EXTRA_ARGS --out foo`` to avoid race conditions writing the result output - when using parallel test execution. + The directory the reports of ``REPORTER`` go to, one file per test, + named after the test. Use it instead of ``EXTRA_ARGS --junit=foo`` so that + tests that run at the same time do not write the same file. ``OUTPUT_PREFIX prefix`` May be used in conjunction with ``OUTPUT_DIR``. If specified, ``prefix`` is added to each output file name, like so - ``--out dir/prefix<test_name>``. + ``dir/prefix<test_name>``. ``OUTPUT_SUFFIX suffix`` May be used in conjunction with ``OUTPUT_DIR``. If specified, ``suffix`` is added to each output file name, like so - ``--out dir/<test_name>suffix``. This can be used to add a file extension to + ``dir/<test_name>suffix``. This can be used to add a file extension to the output e.g. ".xml". ``DL_PATHS path...`` diff --git a/extras/CatchAddTests.cmake b/extras/CatchAddTests.cmake index aba0d001..ef2fb439 100644 --- a/extras/CatchAddTests.cmake +++ b/extras/CatchAddTests.cmake @@ -74,7 +74,7 @@ function(magic_unescape_chars var) endfunction() -# Abuses knowledge of Catch2's JSON reporter output for listing tests to +# Abuses knowledge of the runner's JSON listing (`--list=json`) to # split JSON array of the test listings into a CMake list of strings, # with each element being the JSON string of one array entry. # @@ -286,19 +286,10 @@ function(catch_discover_tests_impl) set(ENV{DYLD_FRAMEWORK_PATH} "${paths}") endif() - make_temp_file_path(listing_output_path "${_TEST_WORKING_DIR}") - - set(_Verbosity "quiet") - if (add_tags) - set(_Verbosity "normal") - endif() - + # The discovery protocol of the Catch3 runner: `--list=json` prints one + # JSON document, "format": "catch3-tests", in registration order. execute_process( - COMMAND ${_TEST_EXECUTOR} "${_TEST_EXECUTABLE}" ${spec} - --list-tests - --reporter "json::verbosity=${_Verbosity}" - --out "${listing_output_path}" - --order lex # Make sure the output order, and thus test registration order, is consistent across runs. + COMMAND ${_TEST_EXECUTOR} "${_TEST_EXECUTABLE}" ${spec} --list=json OUTPUT_VARIABLE listing_output RESULT_VARIABLE result WORKING_DIRECTORY "${_TEST_WORKING_DIR}" @@ -310,34 +301,29 @@ function(catch_discover_tests_impl) " Output: ${listing_output}\n" ) endif() - - # Read the JSON output back from the output file and validate it. - file(READ ${listing_output_path} listing_output) - file(REMOVE ${listing_output_path}) + # Whatever the binary printed before the document is not part of it. + string(FIND "${listing_output}" "{" listing_start) + if(listing_start GREATER 0) + string(SUBSTRING "${listing_output}" ${listing_start} -1 listing_output) + endif() validate_input_noescapes(listing_output) - # Prepare reporter + # The runner writes reports itself: REPORTER chooses which, OUTPUT_DIR where. + set(report_option "") if(reporter) - set(reporter_arg "--reporter ${reporter}") - - # Run test executable to check whether reporter is available - # note that the use of --list-reporters is not the important part, - # we only want to check whether the execution succeeds with ${reporter_arg} - execute_process( - COMMAND ${_TEST_EXECUTOR} "${_TEST_EXECUTABLE}" ${spec} ${reporter_arg} --list-reporters - OUTPUT_VARIABLE reporter_check_output - RESULT_VARIABLE reporter_check_result - WORKING_DIRECTORY "${_TEST_WORKING_DIR}" - ) - if(${reporter_check_result} EQUAL 255) + string(TOLOWER "${reporter}" reporter_lower) + if(reporter_lower STREQUAL "junit") + set(report_option "--junit=") + elseif(reporter_lower STREQUAL "json") + set(report_option "--json=") + elseif(NOT reporter_lower STREQUAL "console") message(FATAL_ERROR - "\"${reporter}\" is not a valid reporter!\n" + "\"${reporter}\" is not a report of the Catch3 runner; use junit, json or console\n" ) - elseif(NOT ${reporter_check_result} EQUAL 0) + endif() + if(report_option AND NOT output_dir) message(FATAL_ERROR - "Error checking for reporter in test executable '${_TEST_EXECUTABLE}':\n" - " Result: ${reporter_check_result}\n" - " Output: ${reporter_check_output}\n" + "REPORTER ${reporter} writes a file and needs OUTPUT_DIR\n" ) endif() endif() @@ -365,14 +351,15 @@ function(catch_discover_tests_impl) endif() # Parse JSON output for list of tests/class names/tags + string(JSON format GET "${listing_output}" "format") string(JSON version GET "${listing_output}" "version") - if(NOT version STREQUAL "2") - message(FATAL_ERROR "Unsupported catch output version: '${version}'") + if(NOT format STREQUAL "catch3-tests" OR NOT version STREQUAL "1") + message(FATAL_ERROR "Unsupported test listing: '${format}' version '${version}'") endif() # Extract just the JSON array with tests and then split them into # individual objects. - string(JSON test_listing GET "${listing_output}" "listings" "tests") + string(JSON test_listing GET "${listing_output}" "tests") split_json_array(test_listing tests) # Exit early if no tests are detected @@ -405,8 +392,6 @@ function(catch_discover_tests_impl) ) prepare_command_fragment(_args_reporter_fragment "${extra_args}" - "${reporter_arg}" - "--order decl" ) # `set_tests_properties` calls are @@ -459,7 +444,9 @@ function(catch_discover_tests_impl) # ...add output dir if(output_dir) string(REGEX REPLACE "[^A-Za-z0-9_]" "_" escaped_name_clean "${escaped_name}") - set(output_dir_arg "--out ${output_dir}/${output_prefix}${escaped_name_clean}${output_suffix}") + if(report_option) + set(output_dir_arg "${report_option}${output_dir}/${output_prefix}${escaped_name_clean}${output_suffix}") + endif() endif() set(full_name "${prefix}${plain_name}${suffix}") diff --git a/extras/CatchShardTests.cmake b/extras/CatchShardTests.cmake index 89666d66..27f7c9f7 100644 --- a/extras/CatchShardTests.cmake +++ b/extras/CatchShardTests.cmake @@ -8,7 +8,7 @@ # Supported optional args: # * SHARD_COUNT - number of shards to split target's tests into -# * REPORTER - reporter spec to use for tests +# * REPORTER - report options for the shards, such as --junit=FILE # * TEST_SPEC - test spec used for filtering tests function(catch_add_sharded_tests TARGET) if(${CMAKE_VERSION} VERSION_LESS "3.10.0") diff --git a/extras/CatchShardTestsImpl.cmake b/extras/CatchShardTestsImpl.cmake index 83fac688..99fd900a 100644 --- a/extras/CatchShardTestsImpl.cmake +++ b/extras/CatchShardTestsImpl.cmake @@ -15,7 +15,8 @@ # * TARGET_NAME - name of the target to shard (used for test names) # * SHARD_COUNT - number of shards to split the binary into # Optional args: -# * REPORTER_SPEC - reporter specs to be passed down to the binary +# * REPORTER_SPEC - report options passed to the binary as they are, such +# as --junit=FILE # * TEST_SPEC - test spec to pass down to the test binary if(NOT EXISTS "${TEST_BINARY}") @@ -29,22 +30,24 @@ if(TEST_SPEC) set(other_args "${other_args} ${TEST_SPEC}") endif() if(REPORTER_SPEC) - set(other_args "${other_args} --reporter ${REPORTER_SPEC}") + set(other_args "${other_args} ${REPORTER_SPEC}") endif() # foreach RANGE in cmake is inclusive of the end, so we have to adjust it math(EXPR adjusted_shard_count "${SHARD_COUNT} - 1") +# The runner assigns tests to shards by a stable hash of their names, so the +# shards cover every test once whatever the seed; the shared seed keeps their +# random numbers the same. file(WRITE "${CTEST_FILE}" - "string(RANDOM LENGTH 8 ALPHABET \"0123456789abcdef\" rng_seed)\n" + "string(RANDOM LENGTH 9 ALPHABET \"0123456789\" rng_seed)\n" "\n" "foreach(shard_idx RANGE ${adjusted_shard_count})\n" " add_test(${TARGET_NAME}-shard-" [[${shard_idx}]] "/${adjusted_shard_count}\n" " ${TEST_BINARY}" - " --shard-index " [[${shard_idx}]] - " --shard-count ${SHARD_COUNT}" - " --rng-seed " [[0x${rng_seed}]] - " --order rand" + " --shard=" [[${shard_idx}]] "/${SHARD_COUNT}" + " --seed=" [[${rng_seed}]] + " --order=random" "${other_args}" "\n" " )\n" diff --git a/extras/catch_amalgamated.cpp b/extras/catch_amalgamated.cpp index 230849f1..217abac4 100644 --- a/extras/catch_amalgamated.cpp +++ b/extras/catch_amalgamated.cpp @@ -7,10 +7,14 @@ // SPDX-License-Identifier: BSL-1.0 // Catch v3.16.1 -// Generated: 2026-10-11 02:09:25.622664 +// Generated: 2026-10-24 17:43:09.597581 // ---------------------------------------------------------- // This file is an amalgamation of multiple different files. // You probably shouldn't edit it directly. +// +// The parts that come from src/Progmasoft, the Catch3 runner, keep +// their own copyright and license lines (MPL-2.0 WITH +// AdditionRef-Progmasoft-Exception-1.1). The runner needs C++20. // ---------------------------------------------------------- #include "catch_amalgamated.hpp" @@ -36,6 +40,2123 @@ #endif // defined(CATCH_PLATFORM_WINDOWS) #endif // CATCH_WINDOWS_H_PROXY_HPP_INCLUDED +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 + +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 + +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 + +#include <cassert> +#include <optional> +#include <string> +#include <utility> +#include <variant> + +namespace Progmasoft::Catch3::Runner +{ + /// A failure with a message written for the person who has to act on it. + class Error final + { + public: + explicit Error(std::string message) + : message_{std::move(message)} + { + } + + [[nodiscard]] const std::string & + Message() const noexcept + { + return message_; + } + + /// The same failure, with what was being done in front of it. + [[nodiscard]] Error + Context(const std::string &context) const + { + return Error{context + ": " + message_}; + } + + private: + std::string message_; + }; + + /// A value, or the error that prevented it. + template<typename Value> + class [[nodiscard]] Expected final + { + public: + Expected(Value value) + : state_{std::in_place_index<0>, std::move(value)} + { + } + + Expected(Error error) + : state_{std::in_place_index<1>, std::move(error)} + { + } + + [[nodiscard]] explicit + operator bool() const noexcept + { + return state_.index() == 0; + } + + [[nodiscard]] Value & + operator*() & + { + assert(*this); + return std::get<0>(state_); + } + + [[nodiscard]] const Value & + operator*() const & + { + assert(*this); + return std::get<0>(state_); + } + + [[nodiscard]] Value && + operator*() && + { + assert(*this); + return std::get<0>(std::move(state_)); + } + + [[nodiscard]] Value * + operator->() + { + assert(*this); + return &std::get<0>(state_); + } + + [[nodiscard]] const Value * + operator->() const + { + assert(*this); + return &std::get<0>(state_); + } + + [[nodiscard]] const Error & + GetError() const + { + assert(!*this); + return std::get<1>(state_); + } + + private: + std::variant<Value, Error> state_; + }; + + /// Success, or the error that happened instead. + class [[nodiscard]] Status final + { + public: + Status() = default; + + Status(Error error) + : error_{std::move(error)} + { + } + + [[nodiscard]] static Status + Ok() + { + return Status{}; + } + + [[nodiscard]] explicit + operator bool() const noexcept + { + return !error_.has_value(); + } + + [[nodiscard]] const Error & + GetError() const + { + assert(error_.has_value()); + return *error_; + } + + private: + std::optional<Error> error_; + }; +} // namespace Progmasoft::Catch3::Runner + +#include <filesystem> +#include <string> +#include <vector> + +namespace Progmasoft::Catch3::Runner +{ + /// What Git says about the checkout the tests come from. + struct Changes final + { + std::filesystem::path root; + std::string commit; + /// Changed since the revision, staged or not, and untracked files; + /// absolute paths. + std::vector<std::filesystem::path> files; + }; + + /// The changes since `revision` (HEAD when empty) in the checkout that + /// holds `directory`. An error when Git or the checkout is missing. + [[nodiscard]] Expected<Changes> + ReadChanges(const std::filesystem::path &directory, + const std::string &revision); + + /// The commit checked out in the checkout that holds `directory`, or + /// empty. + [[nodiscard]] std::string + CurrentCommit(const std::filesystem::path &directory); + + /// Whether two spellings name the same source file: equal after + /// normalisation, or one a relative path the other ends with. + [[nodiscard]] bool + SameSourceFile(const std::filesystem::path &left, + const std::filesystem::path &right); +} // namespace Progmasoft::Catch3::Runner +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 + +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 + +#include <cstddef> +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +namespace Progmasoft::Catch3 +{ + /// How one attempt at a test ended. + enum class Outcome : std::uint8_t + { + kPassed, + /// An assertion failed, or the test threw. + kFailed, + /// It failed, but `[!shouldfail]` or `[!mayfail]` allows that. + kFailedAsExpected, + /// `SKIP` was called. + kSkipped, + /// The process running it died: a signal, a structured exception or + /// an exit in the middle of the test. + kCrashed, + /// It ran past its time limit and was stopped. + kTimedOut, + /// A test it depends on did not pass, so it was not run. + kBlocked, + /// The run stopped before reaching it. + kNotRun, + /// It passed last time and nothing it depends on changed, so the + /// incremental run kept that result. + kUpToDate, + }; + + /// Why a failure happened, as far as the runner can tell. + enum class FailureCategory : std::uint8_t + { + kNone, + /// A REQUIRE or CHECK expression was false. + kAssertion, + /// An exception escaped the test or an assertion. + kUnexpectedException, + /// REQUIRE_THROWS and its kind did not see the expected exception. + kMissingException, + /// FAIL or FAIL_CHECK. + kExplicitFailure, + /// A signal or structured exception inside the test. + kCrash, + kTimeout, + /// The worker could not run the test at all, for example because it + /// failed to start. + kInfrastructure, + /// A test it depends on failed. + kDependency, + }; + + /// How a failure relates to earlier runs. + enum class FailureHistory : std::uint8_t + { + /// There is no history to compare with. + kUnknown, + /// The test passed in the previous run. + kNew, + /// The test failed in the previous run too. + kPersistent, + /// The test passes and fails with the same inputs. + kIntermittent, + }; + + /// One failed or reported assertion. + struct AssertionRecord final + { + std::string file; + std::size_t line = 0; + /// The macro, such as REQUIRE. + std::string macro; + /// The expression as written. + std::string expression; + /// The expression with its operands' values. + std::string expansion; + std::string message; + /// INFO, CAPTURE and UNSCOPED_INFO messages in force. + std::vector<std::string> context; + /// The sections the assertion was in, outermost first. + std::vector<std::string> sections; + FailureCategory category = FailureCategory::kAssertion; + bool passed = false; + /// The failure is allowed by `[!mayfail]` or `[!shouldfail]`. + bool allowed = false; + }; + + /// The statistics of one BENCHMARK. + struct BenchmarkRecord final + { + std::string name; + std::size_t samples = 0; + std::size_t iterations = 0; + double meanNanoseconds = 0.0; + double lowMeanNanoseconds = 0.0; + double highMeanNanoseconds = 0.0; + double standardDeviationNanoseconds = 0.0; + double outlierVariance = 0.0; + }; + + /// One section entered while a test ran, with its timing. + struct SectionRecord final + { + std::string name; + std::string file; + std::size_t line = 0; + /// Microseconds since the start of the attempt. + std::int64_t startMicroseconds = 0; + /// -1 for a section that never ended: the attempt crashed or timed + /// out inside it. + std::int64_t durationMicroseconds = 0; + /// 0 for a section directly in the test case. + std::size_t depth = 0; + }; + + /// One run of a test in a worker. A test may be tried several times: + /// retries, repeats and the probes that look for an order dependency. + struct Attempt final + { + Outcome outcome = Outcome::kNotRun; + FailureCategory category = FailureCategory::kNone; + std::size_t number = 1; + /// The worker that ran it, and the tests it ran before, in order. + std::size_t worker = 0; + std::vector<std::string> precededBy; + bool freshProcess = false; + std::uint32_t seed = 0; + std::int64_t startMicroseconds = 0; + std::int64_t durationMicroseconds = 0; + std::size_t assertionsPassed = 0; + std::size_t assertionsFailed = 0; + std::vector<AssertionRecord> assertions; + std::vector<BenchmarkRecord> benchmarks; + std::vector<SectionRecord> sections; + /// What the test wrote to std::cout and std::cerr, captured. + std::string standardOutput; + std::string standardError; + /// What reached the worker's own streams while the test ran, such as + /// printf output or a sanitizer report. + std::string processOutput; + /// The fatal condition the worker reported before it died. + std::string fatalMessage; + /// How the worker ended, for a crash. + std::string exitDescription; + /// The last assertion started before a crash or a timeout. + std::string lastLocation; + /// The directory the test had for its temporary files; kept when the + /// attempt failed, so its contents can be inspected. + std::string temporaryDirectory; + }; + + /// One test of the run and everything the runner learned about it. + struct TestResult final + { + /// Identifies the test across runs and builds: its name, with its + /// class and, when names repeat, its tags. History is kept by it. + std::string id; + std::string name; + std::string className; + std::string file; + std::size_t line = 0; + std::vector<std::string> tags; + /// The verdict over all attempts. + Outcome outcome = Outcome::kNotRun; + FailureCategory category = FailureCategory::kNone; + FailureHistory history = FailureHistory::kUnknown; + /// It failed and then passed with the same inputs. + bool flaky = false; + /// It is in quarantine, so its failure does not fail the run. + bool quarantined = false; + /// Why it was selected, when not every test runs: "failed last + /// run", "covers a changed file", "dependency of ...". + std::string selectionReason; + /// A stable identifier of the failure: its category, location and + /// message with numbers removed. Tests that share it probably share a + /// cause. + std::string signature; + /// What the runner found out about the failure, one finding per + /// entry, most useful first. + std::vector<std::string> analysis; + /// A command line that runs the failure again the way it happened. + std::string replayCommand; + std::vector<Attempt> attempts; + std::int64_t predictedDurationMicroseconds = 0; + double predictedFailureProbability = 0.0; + + /// The sum of the attempts' durations. + [[nodiscard]] std::int64_t + DurationMicroseconds() const noexcept; + + /// Whether the result fails the run. + [[nodiscard]] bool + FailsRun() const noexcept; + }; + + /// The totals of a run, by verdict. + struct RunSummary final + { + std::size_t tests = 0; + std::size_t passed = 0; + std::size_t failed = 0; + std::size_t failedAsExpected = 0; + std::size_t skipped = 0; + std::size_t crashed = 0; + std::size_t timedOut = 0; + std::size_t blocked = 0; + std::size_t notRun = 0; + std::size_t upToDate = 0; + std::size_t flaky = 0; + std::size_t quarantined = 0; + std::size_t attempts = 0; + std::size_t assertionsPassed = 0; + std::size_t assertionsFailed = 0; + /// Wall-clock time of the run, and the time its tests took added up; + /// their ratio is the speed-up of running in parallel. + std::int64_t wallMicroseconds = 0; + std::int64_t testMicroseconds = 0; + }; + + /// A whole run of one test binary. + struct RunResult final + { + std::string binary; + std::uint32_t seed = 0; + std::size_t workers = 0; + /// The shard, when the run was one of several. + unsigned shardIndex = 0; + unsigned shardCount = 1; + /// The tests that matched before selection narrowed them. + std::size_t candidates = 0; + std::vector<TestResult> tests; + /// Test specs that matched nothing. + std::vector<std::string> unmatchedSpecs; + /// Messages about the run itself, such as a history that could not + /// be read. + std::vector<std::string> notes; + std::int64_t startUnixMilliseconds = 0; + std::int64_t wallMicroseconds = 0; + }; + + [[nodiscard]] RunSummary + Summarize(const RunResult &run); + + /// The name an outcome has in reports: "passed", "timed-out", ... + [[nodiscard]] const char * + OutcomeName(Outcome outcome) noexcept; + + [[nodiscard]] std::optional<Outcome> + OutcomeFromName(const std::string &name) noexcept; + + [[nodiscard]] const char * + CategoryName(FailureCategory category) noexcept; + + [[nodiscard]] std::optional<FailureCategory> + CategoryFromName(const std::string &name) noexcept; + + [[nodiscard]] const char * + HistoryName(FailureHistory history) noexcept; + + /// The failure of an attempt as text: each failed assertion with its + /// expansion, messages and sections, or what is known about a crash or a + /// timeout. Empty for an attempt that did not fail. + [[nodiscard]] std::string + DescribeFailure(const Attempt &attempt); + + /// The first line of `DescribeFailure`, for a summary. + [[nodiscard]] std::string + SummarizeFailure(const Attempt &attempt); + + /// Whether an outcome counts as a failure of the attempt. + [[nodiscard]] bool + IsFailure(Outcome outcome) noexcept; +} // namespace Progmasoft::Catch3 +#include <chrono> +#include <cstdint> +#include <filesystem> +#include <map> +#include <optional> +#include <string> +#include <vector> + +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 + + +#include <cstdint> +#include <initializer_list> +#include <memory> +#include <string> +#include <string_view> +#include <utility> +#include <vector> + +namespace Progmasoft::Catch3::Runner +{ + class JsonValue; + + /// The members of an object, in the order they were added or read. + using JsonMembers = std::vector<std::pair<std::string, JsonValue>>; + + /// One JSON value: the messages between the runner and its workers, the + /// reports, the history and the coverage exports of LLVM are all JSON. + /// + /// Numbers are kept as `double`, which represents every integer the + /// runner writes (counts, microseconds, 32-bit seeds) exactly. + class JsonValue final + { + public: + enum class Kind : std::uint8_t + { + kNull, + kBoolean, + kNumber, + kString, + kArray, + kObject, + }; + + JsonValue() = default; + JsonValue(std::nullptr_t) {} + JsonValue(bool value); + JsonValue(double value); + JsonValue(int value); + JsonValue(unsigned value); + JsonValue(long value); + JsonValue(unsigned long value); + JsonValue(long long value); + JsonValue(unsigned long long value); + JsonValue(const char *value); + JsonValue(std::string value); + JsonValue(std::string_view value); + + JsonValue(const JsonValue &other); + JsonValue(JsonValue &&other) noexcept; + JsonValue & + operator=(const JsonValue &other); + JsonValue & + operator=(JsonValue &&other) noexcept; + ~JsonValue(); + + [[nodiscard]] static JsonValue + Array(std::vector<JsonValue> items = {}); + + [[nodiscard]] static JsonValue + Object(JsonMembers members = {}); + + [[nodiscard]] Kind + GetKind() const noexcept + { + return kind_; + } + + [[nodiscard]] bool + IsNull() const noexcept + { + return kind_ == Kind::kNull; + } + + [[nodiscard]] bool + IsObject() const noexcept + { + return kind_ == Kind::kObject; + } + + [[nodiscard]] bool + IsArray() const noexcept + { + return kind_ == Kind::kArray; + } + + [[nodiscard]] bool + IsString() const noexcept + { + return kind_ == Kind::kString; + } + + [[nodiscard]] bool + IsNumber() const noexcept + { + return kind_ == Kind::kNumber; + } + + /// The value as a Boolean; anything else reads as `fallback`. + [[nodiscard]] bool + AsBoolean(bool fallback = false) const noexcept; + + [[nodiscard]] double + AsNumber(double fallback = 0.0) const noexcept; + + [[nodiscard]] std::int64_t + AsInteger(std::int64_t fallback = 0) const noexcept; + + [[nodiscard]] std::uint64_t + AsUnsigned(std::uint64_t fallback = 0) const noexcept; + + /// The text of a string; anything else reads as the empty string. + [[nodiscard]] const std::string & + AsString() const noexcept; + + /// The items of an array; anything else has none. + [[nodiscard]] const std::vector<JsonValue> & + Items() const noexcept; + + [[nodiscard]] std::vector<JsonValue> & + MutableItems(); + + /// The members of an object; anything else has none. + [[nodiscard]] const JsonMembers & + Members() const noexcept; + + [[nodiscard]] JsonMembers & + MutableMembers(); + + /// The member named `key`, or null when there is none. + [[nodiscard]] const JsonValue & + operator[](std::string_view key) const noexcept; + + /// The member named `key` of an object, added when missing. + JsonValue & + Set(std::string_view key, JsonValue value); + + /// Appends to an array. + void + Append(JsonValue value); + + /// The compact JSON text of the value, on one line. + [[nodiscard]] std::string + ToString() const; + + /// The JSON text with two spaces of indentation per level. + [[nodiscard]] std::string + ToPrettyString() const; + + private: + void + Write(std::string &output, int indent, int depth) const; + + void + Destroy() noexcept; + + void + CopyFrom(const JsonValue &other); + + void + MoveFrom(JsonValue &&other) noexcept; + + Kind kind_ = Kind::kNull; + bool boolean_ = false; + double number_ = 0.0; + std::string string_; + std::unique_ptr<std::vector<JsonValue>> items_; + std::unique_ptr<JsonMembers> members_; + }; + + /// Reads one JSON document; whitespace may surround it. + [[nodiscard]] Expected<JsonValue> + ParseJson(std::string_view text); + + /// Appends `text` to `output` as a JSON string literal. + void + AppendJsonString(std::string &output, std::string_view text); +} // namespace Progmaso |