Files
libakstdlib/CMakeLists.txt
Andrew Kesterson a37ba3fb89
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m47s
libakstdlib CI Build / coverage (push) Successful in 2m38s
libakstdlib CI Build / mutation_test (push) Successful in 9m17s
Namespace the coverage target when embedded
Follows a87cbfb: a sibling dependency may ship a `coverage` target of its
own, so a non-top-level build gets `akstdlib_coverage` instead. The
top-level name is unchanged, and so is every documented command.

Verified: cmake --build build-coverage --target coverage still runs the
suite (14/14) and reports 99.0% of lines; the mutation target still
resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:08:25 -04:00

334 lines
14 KiB
CMake

cmake_minimum_required(VERSION 3.10)
project(akstdlib LANGUAGES C)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -ggdb -pg")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -g -ggdb -pg")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -g -ggdb -pg")
# Sanitizer build, off by default:
# cmake -S . -B build-asan -DAKSL_SANITIZE=ON && ctest --test-dir build-asan
# Set before the dependency is added so libakerror is instrumented too --
# several of the defects in TODO.md section 2 (the uninitialised %s in
# aksl_realpath, the unbounded vsprintf in aksl_sprintf, the missing va_end in
# the printf family) only show up under ASan/UBSan.
option(AKSL_SANITIZE "Build the library and its tests with ASan + UBSan" OFF)
if(AKSL_SANITIZE)
set(AKSL_SANITIZE_FLAGS "-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${AKSL_SANITIZE_FLAGS}")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} ${AKSL_SANITIZE_FLAGS}")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} ${AKSL_SANITIZE_FLAGS}")
message(STATUS "AKSL_SANITIZE=ON: building with ASan + UBSan")
endif()
# Coverage build, off by default:
# cmake -S . -B build-coverage -DAKSL_COVERAGE=ON
# cmake --build build-coverage --target coverage
# Instrumentation is applied per target below (the library and the test
# binaries) rather than through CMAKE_C_FLAGS, so deps/libakerror is left
# uninstrumented -- it has its own suite, and its .gcda would only be noise in
# this project's report.
option(AKSL_COVERAGE "Build the library and its tests with gcov instrumentation" OFF)
# Minimum total line / branch coverage for the `coverage_report` CTest entry
# that a -DAKSL_COVERAGE=ON build adds. 0 disables the gate and reports only.
set(AKSL_COVERAGE_THRESHOLD 0 CACHE STRING
"Fail the coverage_report test below this total line coverage percentage")
set(AKSL_COVERAGE_BRANCH_THRESHOLD 0 CACHE STRING
"Fail the coverage_report test below this total branch coverage percentage")
if(AKSL_COVERAGE)
if(NOT CMAKE_C_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$")
message(FATAL_ERROR
"AKSL_COVERAGE=ON needs a gcov-compatible compiler; "
"CMAKE_C_COMPILER_ID is ${CMAKE_C_COMPILER_ID}")
endif()
# -O0 because the optimizer folds and reorders lines until per-line counts
# stop matching the source. -fprofile-abs-path makes gcov record absolute
# source paths, which is what lets the report be read from any directory.
set(AKSL_COVERAGE_COMPILE_FLAGS --coverage -O0 -g)
if(CMAKE_C_COMPILER_ID STREQUAL "GNU")
list(APPEND AKSL_COVERAGE_COMPILE_FLAGS -fprofile-abs-path)
endif()
message(STATUS "AKSL_COVERAGE=ON: instrumenting akstdlib and its tests for gcov")
endif()
# Add gcov instrumentation to one target. No-op unless AKSL_COVERAGE is set.
function(aksl_target_coverage target)
if(NOT AKSL_COVERAGE)
return()
endif()
target_compile_options(${target} PRIVATE ${AKSL_COVERAGE_COMPILE_FLAGS})
if(CMAKE_VERSION VERSION_LESS 3.13)
set_property(TARGET ${target} APPEND_STRING PROPERTY LINK_FLAGS " --coverage")
else()
target_link_options(${target} PRIVATE --coverage)
endif()
endfunction()
if(TARGET akerror::akerror)
message(STATUS "FOUND akerror::akerror")
else()
message(STATUS "MISSING akerror::akerror")
endif()
include(CTest)
include(GNUInstallDirs)
include(CMakePackageConfigHelpers)
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
# libakerror registers its own CTest entries unconditionally, and because we
# pull it in EXCLUDE_FROM_ALL its test binaries are never built -- so every
# one of them used to show up in our suite as "Not Run" and fail. CMake has no
# way to un-register a test, and set_tests_properties cannot reach across
# directory scopes, so add_test() is shadowed for the duration of the
# add_subdirectory() call. The dependency has its own CI; this project's suite
# should only contain this project's tests.
set(AKSL_SUPPRESS_ADD_TEST TRUE)
function(add_test)
if(NOT AKSL_SUPPRESS_ADD_TEST)
_add_test(${ARGV})
endif()
endfunction()
# libakerror also marks two of those tests WILL_FAIL, which would now be
# setting properties on tests that no longer exist, so suppress that too.
function(set_tests_properties)
if(NOT AKSL_SUPPRESS_ADD_TEST)
_set_tests_properties(${ARGV})
endif()
endfunction()
add_subdirectory(deps/libakerror EXCLUDE_FROM_ALL)
set(AKSL_SUPPRESS_ADD_TEST FALSE)
else()
if(NOT TARGET akerror::akerror)
find_package(PkgConfig REQUIRED)
find_package(akerror REQUIRED)
endif()
endif()
set(akstdlib_install_cmakedir "${CMAKE_INSTALL_LIBDIR}/cmake/akstdlib")
set(prefix ${CMAKE_INSTALL_PREFIX})
set(exec_prefix "\${prefix}")
set(libdir "\${exec_prefix}/lib")
set(includedir "\${prefix}/include")
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/akstdlib.pc.in ${CMAKE_CURRENT_BINARY_DIR}/akstdlib.pc @ONLY)
add_library(akstdlib SHARED
src/stdlib.c
)
add_library(akstdlib::akstdlib ALIAS akstdlib)
# Specify include directories for the library's headers (if applicable)
target_include_directories(akstdlib PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}/>
)
target_link_libraries(akstdlib PUBLIC akerror::akerror)
aksl_target_coverage(akstdlib)
set(main_lib_dest "lib/my_library-${MY_LIBRARY_VERSION}")
install(TARGETS akstdlib
EXPORT akstdlibTargets
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)
install(FILES "include/akstdlib.h" DESTINATION "include/")
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akstdlib.pc DESTINATION "lib/pkgconfig/")
install(EXPORT akstdlibTargets
FILE akstdlibTargets.cmake
NAMESPACE akstdlib::
DESTINATION ${akstdlib_install_cmakedir}
)
configure_package_config_file(
cmake/akstdlib.cmake.in
"${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfig.cmake"
INSTALL_DESTINATION ${akstdlib_install_cmakedir}
)
install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfig.cmake"
DESTINATION ${akstdlib_install_cmakedir}
)
# Each test is one source file tests/test_<name>.c, built into the executable
# test_<name> and registered as the CTest test <name>. Shared helpers live in
# tests/aksl_capture.h.
#
# AKSL_TESTS must exit 0.
# AKSL_WILL_FAIL_TESTS expected to abort by design (an unhandled error
# reaching FINISH_NORETURN, a deliberate contract
# violation), so a non-zero exit is a pass.
# AKSL_KNOWN_FAILING_TESTS assert the *correct* behaviour of a confirmed
# defect from TODO.md section 2.1. They fail until
# the defect is fixed, and are marked WILL_FAIL so
# the suite stays green and the gap stays visible.
# When one is fixed CTest reports it as failed with
# "unexpectedly passed" -- that is the cue to move
# it up into AKSL_TESTS.
set(AKSL_TESTS
convert
format
linkedlist
memory
path
stream
strhash
tree
)
set(AKSL_WILL_FAIL_TESTS
)
set(AKSL_KNOWN_FAILING_TESTS
convert_strict # TODO.md 2.1.5 -- ato* cannot report a bad conversion
list_append_chain # TODO.md 2.1.1 -- append truncates lists of 2+ nodes
list_iterate_head # TODO.md 2.1.2 -- iterate starts at the midpoint
tree_iterate_break # TODO.md 2.1.3 -- ITERATOR_BREAK does not stop a walk
)
foreach(_test IN LISTS AKSL_TESTS AKSL_WILL_FAIL_TESTS AKSL_KNOWN_FAILING_TESTS)
add_executable(test_${_test} tests/test_${_test}.c)
target_include_directories(test_${_test} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests)
target_link_libraries(test_${_test} PRIVATE akstdlib)
# The test binaries are instrumented too. The default report filters them out
# (only src/ and include/ are shown), but `scripts/coverage.py --include
# tests` then answers a different question: whether every test case and
# helper branch in tests/ actually runs, which is how a test function that was
# written but never wired into AKSL_RUN shows up.
aksl_target_coverage(test_${_test})
add_test(NAME ${_test} COMMAND test_${_test})
list(APPEND AKSL_TEST_TARGETS test_${_test})
endforeach()
if(AKSL_WILL_FAIL_TESTS OR AKSL_KNOWN_FAILING_TESTS)
set_tests_properties(
${AKSL_WILL_FAIL_TESTS} ${AKSL_KNOWN_FAILING_TESTS}
PROPERTIES WILL_FAIL TRUE
)
endif()
# Cap every test. The list and tree code is full of loops whose termination
# depends on a single condition, so a plausible bug -- or a mutant from the
# target below -- turns a test into an infinite loop. Without this, ctest waits
# forever; the mutation harness then kills ctest at its own timeout and the
# spinning test binary is left orphaned, holding the pipe open and burning a
# core. The whole suite runs in well under a second, so 30s is pure headroom.
set_tests_properties(
${AKSL_TESTS} ${AKSL_WILL_FAIL_TESTS} ${AKSL_KNOWN_FAILING_TESTS}
PROPERTIES TIMEOUT 30
)
# Both the coverage report and the mutation harness are Python scripts.
find_package(Python3 COMPONENTS Interpreter)
# Code coverage. A -DAKSL_COVERAGE=ON build wires the report into the suite
# itself, so `ctest --test-dir build-coverage` both runs the tests and prints
# what they touched:
#
# coverage_reset deletes the accumulated .gcda counters. gcov counts are
# cumulative, so without this every report would fold in
# earlier runs and overstate coverage. FIXTURES_SETUP makes
# CTest run it before any test that needs the fixture, even
# under `ctest -j`.
# coverage_report aggregates gcov output and prints the summary plus the
# uncovered lines. FIXTURES_CLEANUP makes CTest run it after
# the last test in the fixture, which is exactly when the
# counters are complete.
#
# The report is a plain report unless AKSL_COVERAGE_THRESHOLD is set, in which
# case coverage_report fails below that percentage. Same ratchet idea as the
# mutation threshold: gate on the number you have, raise it as tests land.
#
# CTest hides the output of a passing test, so coverage_report also writes
# <build>/coverage-summary.txt and <build>/coverage.xml (Cobertura). The
# `coverage` target below builds, runs and prints in one step.
if(AKSL_COVERAGE AND Python3_FOUND)
set(AKSL_COVERAGE_ARGS
${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/coverage.py
--build ${CMAKE_CURRENT_BINARY_DIR}
--source-root ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME coverage_reset COMMAND ${AKSL_COVERAGE_ARGS} --zero)
add_test(NAME coverage_report COMMAND ${AKSL_COVERAGE_ARGS}
--threshold ${AKSL_COVERAGE_THRESHOLD}
--branch-threshold ${AKSL_COVERAGE_BRANCH_THRESHOLD}
--output ${CMAKE_CURRENT_BINARY_DIR}/coverage-summary.txt
--cobertura ${CMAKE_CURRENT_BINARY_DIR}/coverage.xml)
set_tests_properties(coverage_reset PROPERTIES FIXTURES_SETUP AKSL_GCDA)
set_tests_properties(coverage_report PROPERTIES FIXTURES_CLEANUP AKSL_GCDA)
set_tests_properties(
${AKSL_TESTS} ${AKSL_WILL_FAIL_TESTS} ${AKSL_KNOWN_FAILING_TESTS}
PROPERTIES FIXTURES_REQUIRED AKSL_GCDA
)
# gcov has to be spawned once per .gcda, so give the report more room than the
# 30s the test binaries get.
set_tests_properties(coverage_reset coverage_report PROPERTIES TIMEOUT 300)
# Namespaced when embedded in another project, for the same reason the mutation
# target below is: a sibling dependency may well ship a `coverage` target too.
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
set(AKSL_COVERAGE_TARGET coverage)
else()
set(AKSL_COVERAGE_TARGET akstdlib_coverage)
endif()
# Convenience entry point that also builds the test binaries first. It runs
# the suite rather than the script directly, because the two fixture tests
# above already reset the counters and produce the report -- and CTest pulls a
# required fixture back in even when it is filtered out, so there is no way to
# run the tests without them. The second command re-reads the same counters to
# print the report that CTest suppressed for the passing coverage_report test
# (it only spawns gcov again, it does not re-run anything).
add_custom_target(${AKSL_COVERAGE_TARGET}
COMMAND ctest --test-dir ${CMAKE_CURRENT_BINARY_DIR} --output-on-failure
COMMAND ${AKSL_COVERAGE_ARGS}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
USES_TERMINAL
COMMENT "Running the test suite under gcov and reporting coverage"
)
add_dependencies(${AKSL_COVERAGE_TARGET} ${AKSL_TEST_TARGETS})
elseif(AKSL_COVERAGE)
message(WARNING "AKSL_COVERAGE=ON but Python3 was not found: "
"instrumenting the build, but the coverage report "
"(scripts/coverage.py) will not be wired into CTest")
endif()
# Mutation testing: break the library in small ways and confirm the test suite
# notices. This is a meta-check on the tests themselves, and it rebuilds and
# re-runs the whole suite once per mutant, so it is a manual target rather than
# a CTest test:
# cmake --build build --target mutation
# When embedded in another project, use a namespaced target to avoid collisions
# with mutation targets provided by sibling dependencies.
# This target covers both src/stdlib.c and include/akstdlib.h. CI runs the
# narrower, faster src/stdlib.c set with a --threshold gate; see
# .gitea/workflows/ci.yaml.
if(Python3_FOUND)
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
set(AKSL_MUTATION_TARGET mutation)
else()
set(AKSL_MUTATION_TARGET akstdlib_mutation)
endif()
add_custom_target(${AKSL_MUTATION_TARGET}
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/scripts/mutation_test.py
--source-root ${CMAKE_CURRENT_SOURCE_DIR}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
USES_TERMINAL
COMMENT "Running mutation tests (breaks the library, expects tests to fail)"
)
endif()
# pkgconfig