From 0136276e15e140cc332630dc83e2a77b796c5fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ho=C5=99e=C5=88ovsk=C3=BD?= Date: Sun, 9 Aug 2026 23:24:00 +0200 Subject: [PATCH] Avoid n**2 runtime when creating list of tests in catch_discover_tests This was another place where the script triggered the quadratic runtime from calling `string(APPEND` (or `list(APPEND`) repeatedly. As in 60c8b87, we avoid this by flushing the test list into a file every 50kB of text input. As the string with the test list never grew quite as much as the string that contains all the test definitions, this only provides significant savings for high number of tests. It starts being properly measurable around 4k tests at ~100ms, but grows to ~4s at 32k tests. --- extras/CatchAddTests.cmake | 53 +++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/extras/CatchAddTests.cmake b/extras/CatchAddTests.cmake index 46a81a85..fa75b3d6 100644 --- a/extras/CatchAddTests.cmake +++ b/extras/CatchAddTests.cmake @@ -204,6 +204,23 @@ function(make_temp_file_path OUT_VARIABLE FALLBACK_PATH) set(${OUT_VARIABLE} "${FINAL_TEMP_PATH}" PARENT_SCOPE) endfunction() +# Computes the path of the test-list file based on the path for the main +# CTest script file (passed in `CTEST_FILE`). +# +# It works by replacing the `_tests` part of the original name with +# `_test-list`, or failing that, it appends `-list.cmake` instead. +# +# _tests.cmake -> _test-list.cmake +# _tests-Debug.cmake -> _test-list-Debug.cmake +function(make_test_list_file_path CTEST_FILE OUT_VARIABLE) + if("${CTEST_FILE}" MATCHES "_tests(.*)\\.cmake$") + string(REGEX REPLACE "_tests(.*)\\.cmake$" "_test-list\\1.cmake" list_file "${CTEST_FILE}") + else() + set(list_file "${CTEST_FILE}-list.cmake") + endif() + set(${OUT_VARIABLE} "${list_file}" PARENT_SCOPE) +endfunction() + function(catch_discover_tests_impl) cmake_parse_arguments( "" @@ -213,9 +230,14 @@ function(catch_discover_tests_impl) ${ARGN} ) - # We periodically append to the output file below, so we have to ensure + # We write the list of all discovered tests into a separate file + make_test_list_file_path("${_CTEST_FILE}" _CTEST_LIST_FILE) + + # We periodically append to the output files below, so we have to ensure # that it is empty at the start, or we get duplicated test scripts. file(REMOVE "${_CTEST_FILE}") + file(REMOVE "${_CTEST_LIST_FILE}") + # Size (in Bytes) at which the intermediate `script` var is dumped to file. set(_WriteToFileThreshold 50000) @@ -360,10 +382,19 @@ function(catch_discover_tests_impl) # Exit early if no tests are detected if(NOT tests) - file(WRITE "${_CTEST_FILE}" "") + # Still emit an (empty) test list file and have the main script include + # it, so that consumers relying on the `${_TEST_LIST}` variable and on the + # include structure get consistent behavior regardless of test count. + file(WRITE "${_CTEST_LIST_FILE}" "set(${_TEST_LIST})\n") + file(WRITE "${_CTEST_FILE}" "include(\"${_CTEST_LIST_FILE}\")\n") return() endif() + + # The 'set(VAR` header for the test list has to be written separately, + # so that each test name can be appended file without further processing. + set(test_names "set(${_TEST_LIST}") + # Each element in the tests is JSON-string representing one test object. # We have to parse it and then turn it into CTest script commands. foreach(single_test IN LISTS tests) @@ -374,6 +405,11 @@ function(catch_discover_tests_impl) file(APPEND "${_CTEST_FILE}" "${script}") set(script "") endif() + string(LENGTH "${test_names}" names_len) + if (names_len GREATER _WriteToFileThreshold) + file(APPEND "${_CTEST_LIST_FILE}" "${test_names}") + set(test_names "") + endif() # The elements are still escaped and contain JSON-invalid characters, # they have to be unescaped before parsing them as JSON. @@ -459,18 +495,17 @@ function(catch_discover_tests_impl) if(full_list_name MATCHES "[^-./:a-zA-Z0-9_]") # The space before the start of quote is important, so that we get # space-separated list in the final file. - string(APPEND _test_names " [==[${full_list_name}]==]") + string(APPEND test_names " [==[${full_list_name}]==]") else() - string(APPEND _test_names " ${full_list_name}") + string(APPEND test_names " ${full_list_name}") endif() endforeach() - # Create a list of all discovered tests, which users may use to e.g. set - # properties on the tests - string(APPEND script "set(${_TEST_LIST}${_test_names})\n") - - # Write any script leftovers we have + # Write any test names leftovers we have + file(APPEND "${_CTEST_LIST_FILE}" "${test_names})\n") + # Write any main script leftovers we have, and append the include of test names file(APPEND "${_CTEST_FILE}" "${script}") + file(APPEND "${_CTEST_FILE}" "include(\"${_CTEST_LIST_FILE}\")\n") endfunction() # To enable `include`ing this file in the unit test scripts, we only run