Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/src/Progmasoft
diff options
context:
space:
mode:
authorLeitwolf112026-09-24 22:07:03 +0300
committerLeitwolf112026-09-24 22:07:03 +0300
commit9ad804cedf4cc4a85ef89fe097f97a3b44654360 (patch)
treef088e919776b22d19387377bdbec727daa2429c0 /src/Progmasoft
parent02f8b5593a15cdec8558256e6f40a0c7fb5862d6 (diff)
downloadcatch3-0.1.0.tar.gz
Add Progmasoft Catch3 property testing and build support0.1.0
Introduce deterministic property execution, built-in generators, shrinking, replayable seeds, and a separate Progmasoft-owned header API while preserving Catch2 compatibility targets and the C++14 engine baseline. Add Bazel, Meson, Xmake, and Premake integration and tests alongside the existing CMake build. Replace Linux-only workflow coverage with Windows and macOS jobs and document the Catch2/Catch3 distinction.
Diffstat (limited to 'src/Progmasoft')
-rw-r--r--src/Progmasoft/BUILD.bazel20
-rw-r--r--src/Progmasoft/Catch3.hpp13
-rw-r--r--src/Progmasoft/Catch3/Assertions.hpp17
-rw-r--r--src/Progmasoft/Catch3/Generator.hpp61
-rw-r--r--src/Progmasoft/Catch3/Generators/Boolean.hpp18
-rw-r--r--src/Progmasoft/Catch3/Generators/Integer.hpp82
-rw-r--r--src/Progmasoft/Catch3/Property.hpp244
-rw-r--r--src/Progmasoft/Catch3/Random.hpp113
-rw-r--r--src/Progmasoft/ProgmasoftCatch3Config.cmake.in7
-rw-r--r--src/Progmasoft/meson.build18
10 files changed, 593 insertions, 0 deletions
diff --git a/src/Progmasoft/BUILD.bazel b/src/Progmasoft/BUILD.bazel
new file mode 100644
index 00000000..9927aafa
--- /dev/null
+++ b/src/Progmasoft/BUILD.bazel
@@ -0,0 +1,20 @@
+# Copyright (c) 2026 Progmasoft.
+# SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+load("@rules_cc//cc:defs.bzl", "cc_library")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+ name = "catch3",
+ hdrs = [
+ "Catch3.hpp",
+ "Catch3/Assertions.hpp",
+ "Catch3/Generator.hpp",
+ "Catch3/Generators/Boolean.hpp",
+ "Catch3/Generators/Integer.hpp",
+ "Catch3/Property.hpp",
+ "Catch3/Random.hpp",
+ ],
+ strip_include_prefix = "/src",
+ deps = ["//:catch2"],
+)
diff --git a/src/Progmasoft/Catch3.hpp b/src/Progmasoft/Catch3.hpp
new file mode 100644
index 00000000..1ae73e53
--- /dev/null
+++ b/src/Progmasoft/Catch3.hpp
@@ -0,0 +1,13 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+// Convenience include: prefer narrow headers when compile time matters.
+#include <catch2/catch_all.hpp>
+
+#include <Progmasoft/Catch3/Assertions.hpp>
+#include <Progmasoft/Catch3/Generator.hpp>
+#include <Progmasoft/Catch3/Generators/Boolean.hpp>
+#include <Progmasoft/Catch3/Generators/Integer.hpp>
+#include <Progmasoft/Catch3/Property.hpp>
+#include <Progmasoft/Catch3/Random.hpp>
diff --git a/src/Progmasoft/Catch3/Assertions.hpp b/src/Progmasoft/Catch3/Assertions.hpp
new file mode 100644
index 00000000..19ac13ef
--- /dev/null
+++ b/src/Progmasoft/Catch3/Assertions.hpp
@@ -0,0 +1,17 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+#include <catch2/catch_message.hpp>
+#include <catch2/catch_test_macros.hpp>
+
+#include <Progmasoft/Catch3/Property.hpp>
+
+/// Runs a property and reports its replay seed and minimized counterexample.
+#define CATCH3_CHECK_PROPERTY( ... ) \
+ do { \
+ const auto catch3PropertyResult = \
+ ::Progmasoft::Catch3::CheckProperty( __VA_ARGS__ ); \
+ INFO( catch3PropertyResult.Describe() ); \
+ CHECK( catch3PropertyResult.Succeeded() ); \
+ } while ( false )
diff --git a/src/Progmasoft/Catch3/Generator.hpp b/src/Progmasoft/Catch3/Generator.hpp
new file mode 100644
index 00000000..0e48aaa3
--- /dev/null
+++ b/src/Progmasoft/Catch3/Generator.hpp
@@ -0,0 +1,61 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+#include <Progmasoft/Catch3/Random.hpp>
+
+#include <functional>
+#include <stdexcept>
+#include <utility>
+#include <vector>
+
+namespace Progmasoft::Catch3 {
+
+ /// A value source paired with an ordered sequence of simpler candidates.
+ ///
+ /// The property runner owns the shrinking policy; generators only describe
+ /// which candidates are valid and which order is most useful for
+ /// minimization.
+ template <typename ValueType>
+ class Generator final {
+ public:
+ using GenerationFunction = std::function<ValueType( Random& )>;
+ using ShrinkingFunction =
+ std::function<std::vector<ValueType>( const ValueType& )>;
+
+ Generator( GenerationFunction generate, ShrinkingFunction shrink ):
+ generate_( std::move( generate ) ), shrink_( std::move( shrink ) ) {
+ if ( !generate_ || !shrink_ ) {
+ throw std::invalid_argument(
+ "a generator requires both callbacks" );
+ }
+ }
+
+ [[nodiscard]] ValueType Generate( Random& random ) const {
+ return generate_( random );
+ }
+
+ [[nodiscard]] std::vector<ValueType>
+ Shrink( const ValueType& value ) const {
+ return shrink_( value );
+ }
+
+ private:
+ GenerationFunction generate_;
+ ShrinkingFunction shrink_;
+ };
+
+ /// Adapts user callables into a reusable generator.
+ template <typename ValueType,
+ typename GenerateFunction,
+ typename ShrinkFunction>
+ [[nodiscard]] Generator<ValueType>
+ MakeGenerator( GenerateFunction&& generate, ShrinkFunction&& shrink ) {
+ return Generator<ValueType>(
+ typename Generator<ValueType>::GenerationFunction(
+ std::forward<GenerateFunction>( generate ) ),
+ typename Generator<ValueType>::ShrinkingFunction(
+ std::forward<ShrinkFunction>( shrink ) ) );
+ }
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/Catch3/Generators/Boolean.hpp b/src/Progmasoft/Catch3/Generators/Boolean.hpp
new file mode 100644
index 00000000..3f881927
--- /dev/null
+++ b/src/Progmasoft/Catch3/Generators/Boolean.hpp
@@ -0,0 +1,18 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+#include <Progmasoft/Catch3/Generator.hpp>
+
+namespace Progmasoft::Catch3 {
+
+ /// Generates booleans and shrinks true to the simplest value, false.
+ [[nodiscard]] inline Generator<bool> Boolean() {
+ return MakeGenerator<bool>(
+ []( Random& random ) { return ( random.Next() & 1U ) != 0; },
+ []( const bool& value ) {
+ return value ? std::vector<bool>{ false } : std::vector<bool>{};
+ } );
+ }
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/Catch3/Generators/Integer.hpp b/src/Progmasoft/Catch3/Generators/Integer.hpp
new file mode 100644
index 00000000..2a50525e
--- /dev/null
+++ b/src/Progmasoft/Catch3/Generators/Integer.hpp
@@ -0,0 +1,82 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+#include <Progmasoft/Catch3/Generator.hpp>
+
+#include <algorithm>
+#include <concepts>
+#include <limits>
+#include <type_traits>
+
+namespace Progmasoft::Catch3 {
+ namespace Detail {
+
+ template <std::integral IntegerType>
+ requires( !std::same_as<IntegerType, bool> )
+ [[nodiscard]] std::vector<IntegerType> ShrinkInteger(
+ IntegerType value, IntegerType minimum, IntegerType maximum ) {
+ std::vector<IntegerType> candidates;
+ const auto addCandidate = [&]( IntegerType candidate ) {
+ if ( candidate < minimum || candidate > maximum ||
+ candidate == value ||
+ std::ranges::find( candidates, candidate ) !=
+ candidates.end() ) {
+ return;
+ }
+ candidates.push_back( candidate );
+ };
+
+ if ( value == 0 ) { return candidates; }
+
+ // First try the conventional simplest value, then halve toward it.
+ // The adjacent value at the end lets the runner find boundary
+ // counterexamples (for example, the first failing integer in a
+ // threshold property).
+ if ( minimum <= 0 && maximum >= 0 ) { addCandidate( 0 ); }
+
+ IntegerType smaller = value;
+ while ( smaller != 0 ) {
+ smaller = static_cast<IntegerType>( smaller / 2 );
+ if ( smaller == 0 ) { break; }
+ addCandidate( smaller );
+ }
+
+ if ( value > 0 ) {
+ if ( value > minimum ) {
+ addCandidate( static_cast<IntegerType>( value - 1 ) );
+ }
+ if ( minimum > 0 ) { addCandidate( minimum ); }
+ } else {
+ if ( value < maximum ) {
+ addCandidate( static_cast<IntegerType>( value + 1 ) );
+ }
+ if ( maximum < 0 ) { addCandidate( maximum ); }
+ }
+ return candidates;
+ }
+
+ } // namespace Detail
+
+ /// Generates integers over the inclusive range and shrinks toward simple
+ /// values.
+ template <std::integral IntegerType>
+ requires( !std::same_as<IntegerType, bool> )
+ [[nodiscard]] Generator<IntegerType> Integer(
+ IntegerType minimum = std::numeric_limits<IntegerType>::lowest(),
+ IntegerType maximum = std::numeric_limits<IntegerType>::max() ) {
+ if ( minimum > maximum ) {
+ throw std::invalid_argument(
+ "Integer requires minimum <= maximum" );
+ }
+
+ return MakeGenerator<IntegerType>(
+ [minimum, maximum]( Random& random ) {
+ return random.Between( minimum, maximum );
+ },
+ [minimum, maximum]( const IntegerType& value ) {
+ return Detail::ShrinkInteger( value, minimum, maximum );
+ } );
+ }
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/Catch3/Property.hpp b/src/Progmasoft/Catch3/Property.hpp
new file mode 100644
index 00000000..57e74408
--- /dev/null
+++ b/src/Progmasoft/Catch3/Property.hpp
@@ -0,0 +1,244 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+#include <catch2/catch_tostring.hpp>
+
+#include <Progmasoft/Catch3/Generator.hpp>
+
+#include <concepts>
+#include <cstddef>
+#include <cstdint>
+#include <exception>
+#include <functional>
+#include <optional>
+#include <sstream>
+#include <string>
+#include <type_traits>
+#include <utility>
+
+namespace Progmasoft::Catch3 {
+
+ /// Controls a property run without changing the compatibility runner's CLI.
+ struct PropertyOptions final {
+ std::size_t Trials = 100;
+ std::uint64_t Seed = 0xC47C4A53ULL;
+ std::size_t MaxShrinkSteps = 1000;
+ };
+
+ enum class PropertyStatus : std::uint8_t {
+ kPassed,
+ kFailed,
+ kErrored,
+ kInvalidConfiguration,
+ };
+
+ struct PropertyFailure final {
+ std::uint64_t RootSeed = 0;
+ std::uint64_t TrialSeed = 0;
+ std::size_t Trial = 0;
+ std::size_t ShrinkSteps = 0;
+ std::string Counterexample;
+ std::string Reason;
+ };
+
+ /// Stable, machine-readable outcome with a deterministic human-readable
+ /// report.
+ struct PropertyResult final {
+ PropertyStatus Status = PropertyStatus::kPassed;
+ std::size_t TrialsRun = 0;
+ std::optional<PropertyFailure> Failure;
+ std::string Error;
+
+ [[nodiscard]] bool Succeeded() const noexcept {
+ return Status == PropertyStatus::kPassed;
+ }
+
+ [[nodiscard]] std::string Describe() const {
+ std::ostringstream output;
+ switch ( Status ) {
+ case PropertyStatus::kPassed:
+ output << "property passed: " << TrialsRun << " trials";
+ return output.str();
+ case PropertyStatus::kInvalidConfiguration:
+ output << "invalid property configuration: " << Error;
+ return output.str();
+ case PropertyStatus::kErrored:
+ output << "property execution errored after " << TrialsRun
+ << " trials: " << Error;
+ break;
+ case PropertyStatus::kFailed:
+ output << "property failed after " << TrialsRun << " trials";
+ break;
+ }
+
+ if ( Failure.has_value() ) {
+ output << "; failing trial " << Failure->Trial << ", root seed "
+ << Failure->RootSeed << ", replay seed "
+ << Failure->TrialSeed << ", shrink checks "
+ << Failure->ShrinkSteps
+ << "\ncounterexample: " << Failure->Counterexample
+ << "\nreason: " << Failure->Reason;
+ }
+ return output.str();
+ }
+ };
+
+ namespace Detail {
+
+ template <typename ValueType>
+ [[nodiscard]] std::string
+ StringifyPropertyValue( const ValueType& value ) {
+ return Catch::StringMaker<std::remove_cvref_t<ValueType>>::convert(
+ value );
+ }
+
+ template <typename Predicate, typename ValueType>
+ [[nodiscard]] std::optional<std::string>
+ EvaluateProperty( Predicate& predicate, const ValueType& value ) {
+ try {
+ if ( static_cast<bool>( std::invoke( predicate, value ) ) ) {
+ return std::nullopt;
+ }
+ return "predicate returned false";
+ } catch ( const std::exception& exception ) {
+ return std::string( "predicate threw std::exception: " ) +
+ exception.what();
+ } catch ( ... ) {
+ return "predicate threw a non-standard exception";
+ }
+ }
+
+ template <typename ValueType>
+ [[nodiscard]] std::string
+ DescribeValue( const ValueType& value ) noexcept {
+ try {
+ return StringifyPropertyValue( value );
+ } catch ( const std::exception& exception ) {
+ return std::string( "<stringification failed: " ) +
+ exception.what() + ">";
+ } catch ( ... ) {
+ return "<stringification failed with a non-standard exception>";
+ }
+ }
+
+ } // namespace Detail
+
+ /// Runs independently seeded trials and greedily accepts failing shrink
+ /// candidates.
+ template <typename ValueType, typename Predicate>
+ requires std::invocable<Predicate&, const ValueType&>&&
+ std::convertible_to<std::invoke_result_t<Predicate&, const ValueType&>,
+ bool> [[nodiscard]] PropertyResult
+ CheckProperty( const Generator<ValueType>& generator,
+ Predicate predicate,
+ PropertyOptions options = {} ) {
+ if ( options.Trials == 0 ) {
+ return { PropertyStatus::kInvalidConfiguration,
+ 0,
+ std::nullopt,
+ "Trials must be greater than zero" };
+ }
+
+ PropertyResult result;
+ for ( std::size_t trial = 0; trial < options.Trials; ++trial ) {
+ result.TrialsRun = trial + 1;
+ const std::uint64_t trialSeed =
+ Random::DeriveSeed( options.Seed, trial );
+ Random random( trialSeed );
+ std::optional<ValueType> sample;
+
+ try {
+ sample.emplace( generator.Generate( random ) );
+ } catch ( const std::exception& exception ) {
+ result.Status = PropertyStatus::kErrored;
+ result.Error =
+ std::string( "generator threw std::exception: " ) +
+ exception.what();
+ result.Failure = PropertyFailure{ options.Seed,
+ trialSeed,
+ trial + 1,
+ 0,
+ "<not generated>",
+ result.Error };
+ return result;
+ } catch ( ... ) {
+ result.Status = PropertyStatus::kErrored;
+ result.Error = "generator threw a non-standard exception";
+ result.Failure = PropertyFailure{ options.Seed,
+ trialSeed,
+ trial + 1,
+ 0,
+ "<not generated>",
+ result.Error };
+ return result;
+ }
+
+ auto reason = Detail::EvaluateProperty( predicate, *sample );
+ if ( !reason.has_value() ) { continue; }
+
+ std::size_t shrinkChecks = 0;
+ try {
+ bool improved = true;
+ while ( improved && shrinkChecks < options.MaxShrinkSteps ) {
+ improved = false;
+ const std::vector<ValueType> candidates =
+ generator.Shrink( *sample );
+ for ( std::size_t candidateIndex = 0;
+ candidateIndex < candidates.size();
+ ++candidateIndex ) {
+ if ( shrinkChecks >= options.MaxShrinkSteps ) { break; }
+ ++shrinkChecks;
+ const ValueType candidate = static_cast<ValueType>(
+ candidates[candidateIndex] );
+ auto candidateReason =
+ Detail::EvaluateProperty( predicate, candidate );
+ if ( candidateReason.has_value() ) {
+ sample.emplace( candidate );
+ reason = std::move( candidateReason );
+ improved = true;
+ break;
+ }
+ }
+ }
+ } catch ( const std::exception& exception ) {
+ result.Status = PropertyStatus::kErrored;
+ result.Error =
+ std::string( "shrinker threw std::exception: " ) +
+ exception.what();
+ result.Failure =
+ PropertyFailure{ options.Seed,
+ trialSeed,
+ trial + 1,
+ shrinkChecks,
+ Detail::DescribeValue( *sample ),
+ *reason };
+ return result;
+ } catch ( ... ) {
+ result.Status = PropertyStatus::kErrored;
+ result.Error = "shrinker threw a non-standard exception";
+ result.Failure =
+ PropertyFailure{ options.Seed,
+ trialSeed,
+ trial + 1,
+ shrinkChecks,
+ Detail::DescribeValue( *sample ),
+ *reason };
+ return result;
+ }
+
+ result.Status = PropertyStatus::kFailed;
+ result.Failure = PropertyFailure{ options.Seed,
+ trialSeed,
+ trial + 1,
+ shrinkChecks,
+ Detail::DescribeValue( *sample ),
+ *reason };
+ return result;
+ }
+
+ result.Status = PropertyStatus::kPassed;
+ return result;
+ }
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/Catch3/Random.hpp b/src/Progmasoft/Catch3/Random.hpp
new file mode 100644
index 00000000..0a95cb84
--- /dev/null
+++ b/src/Progmasoft/Catch3/Random.hpp
@@ -0,0 +1,113 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+#include <bit>
+#include <concepts>
+#include <cstdint>
+#include <limits>
+#include <stdexcept>
+#include <type_traits>
+
+namespace Progmasoft::Catch3 {
+
+ /// Small, deterministic random source for repeatable property-test cases.
+ ///
+ /// The SplitMix64 sequence is specified here instead of delegating to a
+ /// standard-library engine, whose output sequence is not portable between
+ /// library implementations. A Random object is intentionally not
+ /// thread-safe; property trials receive independent instances derived from
+ /// the root seed.
+ class Random final {
+ public:
+ explicit constexpr Random( std::uint64_t seed ) noexcept:
+ state_( seed ) {}
+
+ /// Returns the next value in the stable SplitMix64 sequence.
+ [[nodiscard]] constexpr std::uint64_t Next() noexcept {
+ state_ += kGamma;
+ std::uint64_t value = state_;
+ value = ( value ^ ( value >> 30U ) ) * kMultiplierOne;
+ value = ( value ^ ( value >> 27U ) ) * kMultiplierTwo;
+ return value ^ ( value >> 31U );
+ }
+
+ /// Returns a value in [0, bound); bound == 0 requests the full uint64
+ /// range.
+ [[nodiscard]] constexpr std::uint64_t
+ Below( std::uint64_t bound ) noexcept {
+ if ( bound == 0 ) { return Next(); }
+
+ // Rejection sampling avoids the modulo bias of a single `% bound`.
+ const std::uint64_t threshold =
+ ( std::uint64_t{ 0 } - bound ) % bound;
+ while ( true ) {
+ const std::uint64_t value = Next();
+ if ( value >= threshold ) { return value % bound; }
+ }
+ }
+
+ /// Returns an inclusive integral value without overflowing at type
+ /// limits.
+ template <std::integral IntegerType>
+ requires( !std::same_as<IntegerType, bool> ) [[nodiscard]] IntegerType
+ Between( IntegerType minimum, IntegerType maximum ) {
+ if ( minimum > maximum ) {
+ throw std::invalid_argument(
+ "Random::Between requires minimum <= maximum" );
+ }
+
+ using UnsignedType = std::make_unsigned_t<IntegerType>;
+ static_assert( std::numeric_limits<UnsignedType>::digits <= 64 );
+
+ constexpr unsigned kBitCount = static_cast<unsigned>(
+ std::numeric_limits<UnsignedType>::digits );
+ constexpr UnsignedType kSignBit = UnsignedType{ 1 }
+ << ( kBitCount - 1U );
+
+ const auto encode =
+ []( IntegerType value ) constexpr -> UnsignedType {
+ if constexpr ( std::is_signed_v<IntegerType> ) {
+ return std::bit_cast<UnsignedType>( value ) ^ kSignBit;
+ } else {
+ return value;
+ }
+ };
+ const auto decode =
+ []( UnsignedType value ) constexpr -> IntegerType {
+ if constexpr ( std::is_signed_v<IntegerType> ) {
+ return std::bit_cast<IntegerType>( value ^ kSignBit );
+ } else {
+ return value;
+ }
+ };
+
+ const UnsignedType lower = encode( minimum );
+ const UnsignedType upper = encode( maximum );
+ // Unsigned wrap to zero represents the complete value domain.
+ const UnsignedType width =
+ static_cast<UnsignedType>( upper - lower + UnsignedType{ 1 } );
+ const UnsignedType offset =
+ width == 0 ? static_cast<UnsignedType>( Next() )
+ : static_cast<UnsignedType>(
+ Below( static_cast<std::uint64_t>( width ) ) );
+ return decode( static_cast<UnsignedType>( lower + offset ) );
+ }
+
+ /// Derives an independent, reproducible seed for one trial.
+ [[nodiscard]] static constexpr std::uint64_t
+ DeriveSeed( std::uint64_t rootSeed,
+ std::uint64_t trialIndex ) noexcept {
+ Random trialRandom( rootSeed + kGamma * ( trialIndex + 1U ) );
+ return trialRandom.Next();
+ }
+
+ private:
+ static constexpr std::uint64_t kGamma = 0x9E3779B97F4A7C15ULL;
+ static constexpr std::uint64_t kMultiplierOne = 0xBF58476D1CE4E5B9ULL;
+ static constexpr std::uint64_t kMultiplierTwo = 0x94D049BB133111EBULL;
+
+ std::uint64_t state_;
+ };
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/ProgmasoftCatch3Config.cmake.in b/src/Progmasoft/ProgmasoftCatch3Config.cmake.in
new file mode 100644
index 00000000..a4b3ff31
--- /dev/null
+++ b/src/Progmasoft/ProgmasoftCatch3Config.cmake.in
@@ -0,0 +1,7 @@
+# Copyright (c) 2026 Progmasoft.
+# SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+@PACKAGE_INIT@
+
+include(CMakeFindDependencyMacro)
+find_dependency(Catch2 @PROJECT_VERSION@ CONFIG REQUIRED)
+include("${CMAKE_CURRENT_LIST_DIR}/ProgmasoftCatch3Targets.cmake")
diff --git a/src/Progmasoft/meson.build b/src/Progmasoft/meson.build
new file mode 100644
index 00000000..109edaa4
--- /dev/null
+++ b/src/Progmasoft/meson.build
@@ -0,0 +1,18 @@
+# Copyright (c) 2026 Progmasoft.
+# SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+
+# Expose the header-only C++20 layer without raising Catch2's C++14 baseline.
+progmasoft_catch3_dep = declare_dependency(
+ dependencies: catch2_dep,
+ include_directories: include_directories('..'),
+)
+
+if get_option('install')
+ install_headers('Catch3.hpp', subdir: 'Progmasoft')
+ install_headers('Catch3/Assertions.hpp', subdir: 'Progmasoft/Catch3')
+ install_headers('Catch3/Generator.hpp', subdir: 'Progmasoft/Catch3')
+ install_headers('Catch3/Property.hpp', subdir: 'Progmasoft/Catch3')
+ install_headers('Catch3/Random.hpp', subdir: 'Progmasoft/Catch3')
+ install_headers('Catch3/Generators/Boolean.hpp', subdir: 'Progmasoft/Catch3/Generators')
+ install_headers('Catch3/Generators/Integer.hpp', subdir: 'Progmasoft/Catch3/Generators')
+endif