cmake_minimum_required(VERSION 3.10) # The single source of truth for the version. It flows from here into # include/akstdlib_version.h (generated from the .in template next to it), the # shared library's VERSION/SOVERSION, the Version: field in akstdlib.pc, and # akstdlibConfigVersion.cmake. Nothing else should spell a version number. # # 0.x on purpose: TODO.md section 2.1 still records four confirmed defects whose # fixes change documented behaviour (aksl_atoi's contract, aksl_realpath's # signature), so this library is not promising a stable API yet. project(akstdlib VERSION 0.1.0 LANGUAGES C) # The granularity at which the ABI is allowed to break, and therefore the # soname: libakstdlib.so.0.1. Pre-1.0 that is MAJOR.MINOR, because a 0.x library # makes no compatibility promise across a minor bump. At 1.0 this becomes # ${PROJECT_VERSION_MAJOR} alone -- change it here, and AKSL_VERSION_SONAME in # the header template follows automatically because it is configured from this. if(PROJECT_VERSION_MAJOR EQUAL 0) set(AKSL_SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}") else() set(AKSL_SOVERSION "${PROJECT_VERSION_MAJOR}") endif() set(AKSL_GENERATED_INCLUDE_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated/include") set(AKSL_VERSION_HEADER "${AKSL_GENERATED_INCLUDE_DIR}/akstdlib_version.h") configure_file( "${CMAKE_CURRENT_SOURCE_DIR}/include/akstdlib_version.h.in" "${AKSL_VERSION_HEADER}" @ONLY ) # This used to be three lines setting CMAKE_CXX_FLAGS and the linker flags to # "-g -ggdb -pg". In a LANGUAGES C project the CXX variable reaches no compiler # at all, so the debug flags never applied -- but -pg did reach the linkers, and # every test binary that ran dropped a gmon.out in the working directory. Nobody # was reading those profiles. CMAKE_BUILD_TYPE controls debug info now, which is # what it is for; add -pg deliberately when you actually want to profile. # # Debug is the default for a bare `cmake -S . -B build` because this is a # library under active development and a stripped -O3 build is not what anyone # configuring it without an opinion is asking for. if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Debug CACHE STRING "Build type" FORCE) message(STATUS "CMAKE_BUILD_TYPE was unset; defaulting to Debug") endif() # Warnings. The only warning in the tree when these went on was the unused # `queue` parameter of aksl_tree_iterate, which TODO.md 2.2.8 had already # recorded as a dead parameter -- so the cost of turning them on was one # already-known defect, and the cost of leaving them off was every future one. # # -Wpedantic is deliberately not here. libakerror's FAIL_* macros take a message # plus varargs, and a call with a bare message and no arguments trips "ISO C99 # requires at least one argument for the ...", which is a complaint about the # macro's shape rather than about anything at this call site. Every FAIL_* in # src/stdlib.c passes at least one argument regardless -- see the note in # TODO.md 2.3 -- so the code is pedantic-clean; it is the expansion that is not. if(CMAKE_C_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$") add_compile_options(-Wall -Wextra) # CI turns this on. Locally it is off, because a warning that stops the build # while you are in the middle of something is a good way to teach people to # turn warnings off. option(AKSL_WERROR "Treat compiler warnings as errors" OFF) if(AKSL_WERROR) add_compile_options(-Werror) endif() endif() # 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() # libakerror namespaces its `mutation` target when it is embedded but not its # `coverage` target (deps/libakerror/CMakeLists.txt:172 vs :189), so a # -DAKSL_COVERAGE=ON build hits "another target with the same name already # exists" against the `coverage` target this project adds, and fails to # configure at all. Rename the dependency's on the way past rather than # dropping it: its coverage script drives its own instrumented build tree, so # `cmake --build build-coverage --target akerror_coverage` still does the right # thing. Remove this once the dependency namespaces it upstream -- see TODO.md. function(add_custom_target _name) if(AKSL_SUPPRESS_ADD_TEST AND _name STREQUAL "coverage") _add_custom_target(akerror_coverage ${ARGN}) else() _add_custom_target(${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) # One translation unit per surface. src/stdlib.c was the whole library when it # covered 16 libc calls; it is split by domain now that it does not, so that a # file can still be read start to finish. src/aksl_internal.h carries what they # share and is not installed. add_library(akstdlib SHARED src/stdlib.c src/string.c src/stream.c src/collections.c ) add_library(akstdlib::akstdlib ALIAS akstdlib) # Specify include directories for the library's headers (if applicable). # The generated directory carries akstdlib_version.h, which akstdlib.h includes; # it is PUBLIC because consumers building against the build tree need it too. On # install both headers land side by side in ${CMAKE_INSTALL_INCLUDEDIR}, so the # install interface needs no second entry. target_include_directories(akstdlib PUBLIC $ $ $ ) # VERSION gives the real file (libakstdlib.so.0.1.0); SOVERSION gives the symlink # and the ELF soname recorded in every consumer (libakstdlib.so.0.1), so a # consumer built against 0.1 will not silently load an ABI-incompatible 0.2. set_target_properties(akstdlib PROPERTIES VERSION ${PROJECT_VERSION} SOVERSION ${AKSL_SOVERSION} ) 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 "${AKSL_VERSION_HEADER}" 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} ) # Without this, find_package(akstdlib 0.1 REQUIRED) is refused for want of a # version file no matter what is installed -- which is exactly the gap that # stops cmake/akstdlib.cmake.in from requesting a version of akerror. # # SameMinorVersion mirrors the soname: pre-1.0, 0.1 and 0.2 are different ABIs. # It arrived in CMake 3.11 and this project declares 3.10, so fall back to # ExactVersion on older CMake. That is stricter than the soname rule -- it pins # the patch level too, so a 0.1.0 request refuses a compatible 0.1.1 -- but it is # never laxer, and wrongly refusing a good pairing beats wrongly accepting a bad # one. Drop the branch when the minimum moves past 3.11. if(CMAKE_VERSION VERSION_LESS 3.11) set(AKSL_VERSION_COMPATIBILITY ExactVersion) else() set(AKSL_VERSION_COMPATIBILITY SameMinorVersion) endif() write_basic_package_version_file( "${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfigVersion.cmake" VERSION ${PROJECT_VERSION} COMPATIBILITY ${AKSL_VERSION_COMPATIBILITY} ) install(FILES "${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfig.cmake" "${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfigVersion.cmake" DESTINATION ${akstdlib_install_cmakedir} ) # Each test is one source file tests/test_.c, built into the executable # test_ and registered as the CTest test . 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 collections convert format hashmap linkedlist memory path status_registry strbuf stream streamio strhash string strto tree version ) set(AKSL_WILL_FAIL_TESTS ) # Empty, and that is the news. It held four entries -- convert_strict, # list_append_chain, list_iterate_head and tree_iterate_break -- one for each # confirmed defect in TODO.md 2.1. All four are fixed, so each of those files # was folded back into the test for the thing it was testing (tests/ # test_convert.c, tests/test_linkedlist.c and tests/test_tree.c) where it now # has to keep passing rather than merely keep failing visibly. set(AKSL_KNOWN_FAILING_TESTS ) 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() # tests/test_memory.c asks for SIZE_MAX/2 bytes to check that a refused # allocation reports ENOMEM and leaves *dst NULL. Plain malloc returns NULL and # sets ENOMEM, which is the case under test; ASan's allocator instead treats a # request that large as a bug in the caller and aborts the process before malloc # ever returns. allocator_may_return_null=1 restores the libc behaviour for this # one binary, so the sanitizer build tests the same contract as the normal one # rather than skipping it. if(AKSL_SANITIZE AND "memory" IN_LIST AKSL_TESTS) set_tests_properties(memory PROPERTIES ENVIRONMENT "ASAN_OPTIONS=allocator_may_return_null=1") endif() 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 # /coverage-summary.txt and /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