Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Match the assertion to the tested property:
* **Conditional Specifications**: Pair negative assertions with their positive converses (e.g., pair `static_assert(not noexcept(expr))` with a witness for which it is `noexcept`). This proves that the specification is conditional rather than unconditionally false. Apply the same positive-control discipline to negative constraint or viability probes.
* **Substitution Contexts**: Ensure expressions in negative viability probes are dependent on a template parameter. A `requires`-expression over concrete types is not a substitution context, meaning an invalid requirement triggers a hard error rather than a SFINAE failure. Enclose the dependent probe in a generic lambda or a type-keyed concept to yield `false` on invalid substitution.
* **Exception Fixtures**: Select exception-proving fixtures carefully. The library's `helper_t` implements a separate constructor for each value category, and its non-const-lvalue copy constructor is always `noexcept`. A `helper_t` configured to throw is therefore not throwing for every value category, which can hide incorrect `noexcept` specifications. Use a simple, local type if the test requires every relevant constructor to be potentially throwing.
* **Expected Failures**: Put a statement that must not compile in a source under its suite's `reject/` directory (`tests/pfn/reject/` or `tests/fn/reject/`), alone in an `#ifdef LIBFN_REJECT_<scenario>` block, after its positive control: the same expression with a fixture differing only in what makes it fail. The build compiles each source as is, which must succeed; CTest compiles it once per guard with that guard defined, which must fail. The suite cannot test runtime crashes; record these untested cases beside their positive controls.
* **Expected Failures**: Put a statement that must not compile in a source under its suite's `reject/` directory (`tests/pfn/reject/` or `tests/fn/reject/`), alone in an `#ifdef LIBFN_REJECT_<scenario>` block, after its positive control: the same expression with a fixture differing only in what makes it fail. The build compiles each source as is, which must succeed; CTest compiles it once per guard with that guard defined, which must fail. Under Ninja and Makefile generators, the test passes only if the compiler reports an error at a source line, other than an internal compiler error (see `cmake/RejectLauncher.cmake`); a build failing for any other reason fails the test, so these tests may run concurrently. Under other generators, such as Visual Studio, any failed scenario build passes, so these tests run one at a time. The suite cannot test runtime crashes; record these untested cases beside their positive controls.

## Client code

Expand Down
49 changes: 49 additions & 0 deletions cmake/RejectLauncher.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Copyright (c) 2026 Bronek Kozicki
#
# Distributed under the ISC License. See accompanying file LICENSE.md
# or copy at https://opensource.org/licenses/ISC

# Compiler launcher for reject tests: runs the compile command following `--` and fails whenever it
# fails, printing the marker a reject test requires, "The compiler rejected the source", only if the
# compiler reported an error at a source line.
# Usage: cmake -P RejectLauncher.cmake -- <compiler> [<argument>...]

cmake_minimum_required(VERSION 3.25)

# Bracket arguments forward each argument verbatim; expanding a list would split an argument
# containing `;` and join arguments following an unbalanced `[`
set(command "")
set(forwarding FALSE)
math(EXPR last "${CMAKE_ARGC} - 1")
foreach(index RANGE ${last})
set(argument "${CMAKE_ARGV${index}}")
if(forwarding)
string(FIND "${argument}" "]=]" clash)
if(NOT clash EQUAL -1)
message(FATAL_ERROR "Cannot forward an argument containing `]=]`: ${argument}")
endif()
string(APPEND command " [=[${argument}]=]")
elseif(argument STREQUAL "--")
set(forwarding TRUE)
endif()
endforeach()

cmake_language(EVAL CODE "execute_process(COMMAND ${command} RESULT_VARIABLE result
OUTPUT_VARIABLE output ERROR_VARIABLE output ECHO_OUTPUT_VARIABLE ECHO_ERROR_VARIABLE)")
if(NOT result MATCHES "^[0-9]+$")
message(FATAL_ERROR "The compiler did not run to completion: ${result}")
elseif(result EQUAL 0)
return()
endif()

# A diagnosed error names its source line, as `file:line[:column]: error` (GCC, Clang) or
# `file(line[,column]): error` (MSVC, clang-cl). A failing driver, such as GCC reporting its killed
# front end, names no line. Lines reporting an internal compiler error are dropped first, since they
# name a source line too, and MSVC's would otherwise match.
string(ASCII 27 escape)
string(REGEX REPLACE "${escape}\\[[0-9;]*[A-Za-z]" "" output "${output}")
string(REGEX REPLACE "[^\n]*[Ii]nternal (compiler )?error[^\n]*" "" output "${output}")
if(output MATCHES "(^|\n)[^\n]+(:[0-9]+(:[0-9]+)?: |\\([0-9]+(,[0-9]+)?\\) ?: )(fatal )?error[: ]")
Comment thread
Bronek marked this conversation as resolved.
Resolved
message(FATAL_ERROR "The compiler rejected the source")
endif()
message(FATAL_ERROR "The compiler failed without reporting an error at a source line")
98 changes: 98 additions & 0 deletions cmake/RejectTests.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Copyright (c) 2026 Bronek Kozicki
#
# Distributed under the ISC License. See accompanying file LICENSE.md
# or copy at https://opensource.org/licenses/ISC

include(CompilationOptions)

set(REJECT_LAUNCHER "${CMAKE_CURRENT_LIST_DIR}/RejectLauncher.cmake")
# Only Makefile and Ninja generators run compiler launchers. Targets using this one replace any
# global launcher, such as ccache, whose own failure would pass as a rejection.
if(CMAKE_GENERATOR MATCHES "Makefiles|Ninja")
set(REJECT_LAUNCH "${CMAKE_COMMAND}" -P "${REJECT_LAUNCHER}" --)
endif()
# RejectLauncher.cmake prints this exact text
set(REJECT_MARKER "The compiler rejected the source")

add_custom_target(tests_reject_controls)
add_test(
NAME tests_reject_controls
COMMAND "${CMAKE_COMMAND}" --build "${CMAKE_BINARY_DIR}" --config "$<CONFIG>" --target tests_reject_controls
)

# Scenario tests require this fixture, which other setup tests may join. A rejection counts only
# once each reject source compiles without a scenario, through the same launcher, if any, as its
# scenarios. Building the controls first also regenerates a stale build system once, rather than in
# each scenario build.
set_tests_properties(tests_reject_controls PROPERTIES FIXTURES_SETUP reject_setup)

# Generate an object target for SOURCE, which must compile, and for each
# `#ifdef LIBFN_REJECT_<scenario>` guard in it a test building SOURCE with only that guard defined,
# which the compiler must reject
function(add_reject_tests)
set(oneValueArgs SOURCE MODE)
set(multiValueArgs DEPENDENCIES LABELS)
cmake_parse_arguments(Reject "" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})

set(path "${CMAKE_CURRENT_SOURCE_DIR}/${Reject_SOURCE}")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${path}")
file(STRINGS "${path}" guards REGEX "^#ifdef LIBFN_REJECT_[A-Za-z0-9_]+$")
file(STRINGS "${path}" directives REGEX "^[ \t]*#.*LIBFN_REJECT_")
if(NOT guards OR NOT guards STREQUAL directives)
message(FATAL_ERROR "${Reject_SOURCE}: needs at least one guard, each spelled `#ifdef LIBFN_REJECT_<scenario>`")
endif()
list(TRANSFORM guards REPLACE "^#ifdef LIBFN_REJECT_" "" OUTPUT_VARIABLE scenarios)

string(REGEX REPLACE "\\.cpp$" "" root_name "${Reject_SOURCE}")
string(REPLACE "/" "_" root_name "${root_name}")
set(control "tests_${root_name}_cxx${Reject_MODE}")
add_library("${control}" OBJECT "${Reject_SOURCE}")
add_dependencies("cxx${Reject_MODE}" "${control}")
add_dependencies("tests" "${control}")
set(targets "${control}")

if(REJECT_LAUNCH)
# Positive control for the scenarios, compiled through their launcher; the plain control
# above keeps any global launcher instead
set(launched "${control}_launched")
add_library("${launched}" OBJECT EXCLUDE_FROM_ALL "${Reject_SOURCE}")
set_property(TARGET "${launched}" PROPERTY CXX_COMPILER_LAUNCHER ${REJECT_LAUNCH})
add_dependencies(tests_reject_controls "${launched}")
# The build tracks the launcher's path, not its content; as a source property, this also
# recompiles the plain control and the scenarios when the launcher changes
set_property(SOURCE "${Reject_SOURCE}" PROPERTY OBJECT_DEPENDS "${REJECT_LAUNCHER}")
list(APPEND targets "${launched}")
else()
add_dependencies(tests_reject_controls "${control}")
endif()

foreach(scenario IN LISTS scenarios)
set(target "tests_${root_name}_${scenario}_cxx${Reject_MODE}")
add_library("${target}" OBJECT EXCLUDE_FROM_ALL "${Reject_SOURCE}")
target_compile_definitions("${target}" PRIVATE "LIBFN_REJECT_${scenario}")

add_test(
NAME "${target}"
COMMAND "${CMAKE_COMMAND}" --build "${CMAKE_BINARY_DIR}" --config "$<CONFIG>" --target "${target}"
)
set_tests_properties("${target}" PROPERTIES
FIXTURES_REQUIRED reject_setup
LABELS "${Reject_LABELS};cxx${Reject_MODE};${root_name}")
if(REJECT_LAUNCH)
set_property(TARGET "${target}" PROPERTY CXX_COMPILER_LAUNCHER ${REJECT_LAUNCH})
set_tests_properties("${target}" PROPERTIES PASS_REGULAR_EXPRESSION "${REJECT_MARKER}")
else()
# Any failed build passes here, even one failed by a concurrent build in the same tree
set_tests_properties("${target}" PROPERTIES WILL_FAIL TRUE RESOURCE_LOCK build_tree)
endif()
list(APPEND targets "${target}")

unset(target)
endforeach()

foreach(target IN LISTS targets)
target_link_libraries("${target}" PRIVATE ${Reject_DEPENDENCIES})
append_compilation_options("${target}" WARNINGS)
set_property(TARGET "${target}" PROPERTY CXX_STANDARD "${Reject_MODE}")
endforeach()
endfunction()
61 changes: 11 additions & 50 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ project(tests)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

include(TargetGenerator)
include(CompilationOptions)

### tests/util

set(TESTS_UTIL_HEADERS
Expand Down Expand Up @@ -46,57 +49,15 @@ endforeach()

### reject tests

include(TargetGenerator)
include(CompilationOptions)

# Generate an object target for SOURCE, which must compile, and for each
# `#ifdef LIBFN_REJECT_<scenario>` guard in it a test building SOURCE with only that guard defined,
# which must fail
function(add_reject_tests)
set(oneValueArgs SOURCE MODE)
set(multiValueArgs DEPENDENCIES LABELS)
cmake_parse_arguments(Reject "" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})

set(path "${CMAKE_CURRENT_SOURCE_DIR}/${Reject_SOURCE}")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${path}")
file(STRINGS "${path}" guards REGEX "^#ifdef LIBFN_REJECT_[A-Za-z0-9_]+$")
file(STRINGS "${path}" directives REGEX "^[ \t]*#.*LIBFN_REJECT_")
if(NOT guards OR NOT guards STREQUAL directives)
message(FATAL_ERROR "${Reject_SOURCE}: needs at least one guard, each spelled `#ifdef LIBFN_REJECT_<scenario>`")
endif()
list(TRANSFORM guards REPLACE "^#ifdef LIBFN_REJECT_" "" OUTPUT_VARIABLE scenarios)

string(REGEX REPLACE "\\.cpp$" "" root_name "${Reject_SOURCE}")
string(REPLACE "/" "_" root_name "${root_name}")
set(control "tests_${root_name}_cxx${Reject_MODE}")
add_library("${control}" OBJECT "${Reject_SOURCE}")
add_dependencies("cxx${Reject_MODE}" "${control}")
add_dependencies("tests" "${control}")
set(targets "${control}")
include(RejectTests)

foreach(scenario IN LISTS scenarios)
set(target "tests_${root_name}_${scenario}_cxx${Reject_MODE}")
add_library("${target}" OBJECT EXCLUDE_FROM_ALL "${Reject_SOURCE}")
target_compile_definitions("${target}" PRIVATE "LIBFN_REJECT_${scenario}")

add_test(
NAME "${target}"
COMMAND "${CMAKE_COMMAND}" --build "${CMAKE_BINARY_DIR}" --config "$<CONFIG>" --target "${target}"
)
# Concurrent builds in one tree would race on its build-system state
set_tests_properties("${target}" PROPERTIES
WILL_FAIL TRUE
RESOURCE_LOCK build_tree
LABELS "${Reject_LABELS};cxx${Reject_MODE};${root_name}")
list(APPEND targets "${target}")
endforeach()

foreach(target IN LISTS targets)
target_link_libraries("${target}" PRIVATE ${Reject_DEPENDENCIES})
append_compilation_options("${target}" WARNINGS)
set_property(TARGET "${target}" PROPERTY CXX_STANDARD "${Reject_MODE}")
endforeach()
endfunction()
# Where scenarios use the launcher, a rejection counts only once it passes its self-test
add_test(
NAME tests_reject_launcher
COMMAND "${CMAKE_COMMAND}" "-DLAUNCHER=${REJECT_LAUNCHER}" "-DMARKER=${REJECT_MARKER}"
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/reject_launcher.cmake"
)
set_tests_properties(tests_reject_launcher PROPERTIES FIXTURES_SETUP reject_setup)

### tests/pfn

Expand Down
69 changes: 69 additions & 0 deletions tests/cmake/reject_launcher.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Copyright (c) 2026 Bronek Kozicki
#
# Distributed under the ISC License. See accompanying file LICENSE.md
# or copy at https://opensource.org/licenses/ISC

# Usage: cmake -DLAUNCHER=<RejectLauncher.cmake> -DMARKER=<marker> -P reject_launcher.cmake

cmake_minimum_required(VERSION 3.25)

# With LIBFN_REJECT_DIAGNOSTIC in its environment, this script stands in for a compiler which prints
# it and fails; CMake's own error line does not take the shape of a diagnostic. The environment
# carries the diagnostic because a -D argument, if the launcher mangled it, could start a configure
# run in the current directory.
if(DEFINED ENV{LIBFN_REJECT_DIAGNOSTIC})
message(NOTICE "$ENV{LIBFN_REJECT_DIAGNOSTIC}")
message(FATAL_ERROR "")
endif()

set(launch "${CMAKE_COMMAND}" -P "${LAUNCHER}" --)

function(launch_failing diagnostic)
set(ENV{LIBFN_REJECT_DIAGNOSTIC} "${diagnostic}")
execute_process(
COMMAND ${launch} "${CMAKE_COMMAND}" -P "${CMAKE_CURRENT_FUNCTION_LIST_FILE}"
RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE output)
unset(ENV{LIBFN_REJECT_DIAGNOSTIC})
set(result "${result}" PARENT_SCOPE)
set(output "${output}" PARENT_SCOPE)
endfunction()

string(ASCII 27 escape)
foreach(diagnostic IN ITEMS
"source.cpp:12:3: error: rejected"
"source.cpp:12: error: rejected"
"source.cpp:3:10: fatal error: missing.hpp: No such file or directory"
"source.cpp(12): error C2672: rejected"
"source.cpp(12,3): error: rejected"
"source.cpp:12:3: error: rejected\nsource.cpp:20:1: internal compiler error: Segmentation fault"
"${escape}[01m${escape}[Ksource.cpp:12:3:${escape}[m${escape}[K ${escape}[01;31m${escape}[Kerror: ${escape}[m${escape}[Krejected")
launch_failing("${diagnostic}")
if(result EQUAL 0 OR NOT output MATCHES "${MARKER}")
message(SEND_ERROR "An error at a source line must fail the launch with the marker, got ${result}:\n${output}")
endif()
endforeach()

foreach(diagnostic IN ITEMS
"g++: fatal error: Killed signal terminated program cc1plus"
"source.cpp:12:3: internal compiler error: Segmentation fault"
"source.cpp(61,1): fatal error C1001: Internal compiler error.\n(compiler file 'msc1.cpp', line 1589)"
"source.cpp(12): fatal error C1001: An internal error has occurred in the compiler."
"cc1plus: error: unrecognized command-line option '-fnone'"
"source.cpp:12:3: warning: unused variable 'error'")
launch_failing("${diagnostic}")
if(result EQUAL 0 OR output MATCHES "${MARKER}")
message(SEND_ERROR "A failure without an error at a source line must fail the launch without the marker, got ${result}:\n${output}")
endif()
endforeach()

execute_process(COMMAND ${launch} "${CMAKE_COMMAND}" -E echo "a;b" "[c" "d]"
RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE output OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT result EQUAL 0 OR NOT output STREQUAL "a;b [c d]")
message(SEND_ERROR "A succeeding command must succeed with its arguments intact, got ${result}:\n${output}")
endif()

execute_process(COMMAND ${launch} "${CMAKE_CURRENT_LIST_DIR}/no-such-compiler"
RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE output)
if(result EQUAL 0 OR output MATCHES "${MARKER}")
message(SEND_ERROR "A command that cannot start must fail the launch without the marker, got ${result}:\n${output}")
endif()
Loading