|
1 | 1 | # Distributed under the OSI-approved BSD 3-Clause License. See accompanying |
2 | 2 | # file Copyright.txt or https://cmake.org/licensing for details. |
3 | 3 |
|
| 4 | +# Because natively using CMake's JSON processing for arrays leads to quadratic |
| 5 | +# running times, we do terrible hack and split JSON array by object boundary |
| 6 | +# + commas and try to reconstruct valid JSON objects. During this, we need |
| 7 | +# to replace CMake characters that could be in the test name/tags with |
| 8 | +# placeholder, so it doesn't affect CMake's processing of the strings/lists |
| 9 | +# we create during the parsing. |
| 10 | +# |
| 11 | +# We use 0x01, 0x02, 0x03, and 0x04 as placeholder bytes, as those cannot |
| 12 | +# exist in JSON unescaped. |
| 13 | +# |
| 14 | +# 0x01 <=> ';' (CMake list separator) |
| 15 | +# 0x02 == element boundary marker used while splitting the tests array |
| 16 | +# 0x03 <-> '[' (opens a CMake bracket-argument context) |
| 17 | +# 0x04 <-> ']' (closes a CMake bracket-argument context) |
| 18 | +# |
| 19 | +function(get_placeholder_bytes semicolon_var boundary_var open_bracket_var close_bracket_var) |
| 20 | + string(ASCII 1 semicolon) |
| 21 | + string(ASCII 2 boundary) |
| 22 | + string(ASCII 3 open_bracket) |
| 23 | + string(ASCII 4 close_bracket) |
| 24 | + set(${semicolon_var} "${semicolon}" PARENT_SCOPE) |
| 25 | + set(${boundary_var} "${boundary}" PARENT_SCOPE) |
| 26 | + set(${open_bracket_var} "${open_bracket}" PARENT_SCOPE) |
| 27 | + set(${close_bracket_var} "${close_bracket}" PARENT_SCOPE) |
| 28 | +endfunction() |
| 29 | + |
| 30 | + |
| 31 | +# Placeholder bytes in the listing would break our parsing hack, so |
| 32 | +# we check they don't exist. They shouldn't exist in valid JSON, but |
| 33 | +# the reporter might not be escaping them properly. |
| 34 | +function(validate_input_noescapes listing_var) |
| 35 | + get_placeholder_bytes(semicolon boundary open_bracket close_bracket) |
| 36 | + foreach(byte "${semicolon}" "${boundary}" "${open_bracket}" "${close_bracket}") |
| 37 | + string(FIND "${${listing_var}}" "${byte}" found) |
| 38 | + if(NOT found EQUAL -1) |
| 39 | + message(FATAL_ERROR |
| 40 | + "The test listing contains raw control byte (0x01-0x04) which should not " |
| 41 | + "be there. This means either bad escaping in JSON reporter, or corrupted file. " |
| 42 | + ) |
| 43 | + endif() |
| 44 | + endforeach() |
| 45 | +endfunction() |
| 46 | + |
| 47 | + |
| 48 | +# Replaces relevant characters with their placeholders, see `get_placeholder_bytes` |
| 49 | +# Modifies argument `var` in place. |
| 50 | +function(magic_escape_chars var) |
| 51 | + get_placeholder_bytes(semicolon boundary open_bracket close_bracket) |
| 52 | + set(value "${${var}}") |
| 53 | + string(REPLACE ";" "${semicolon}" value "${value}") |
| 54 | + string(REPLACE "[" "${open_bracket}" value "${value}") |
| 55 | + string(REPLACE "]" "${close_bracket}" value "${value}") |
| 56 | + set(${var} "${value}" PARENT_SCOPE) |
| 57 | +endfunction() |
| 58 | + |
| 59 | + |
| 60 | +# Turns placeholders back into original characters, see `get_placeholder_bytes` |
| 61 | +# Modifies argument `var` in place. |
| 62 | +function(magic_unescape_chars var) |
| 63 | + get_placeholder_bytes(semicolon boundary open_bracket close_bracket) |
| 64 | + set(value "${${var}}") |
| 65 | + string(REPLACE "${semicolon}" ";" value "${value}") |
| 66 | + string(REPLACE "${open_bracket}" "[" value "${value}") |
| 67 | + string(REPLACE "${close_bracket}" "]" value "${value}") |
| 68 | + set(${var} "${value}" PARENT_SCOPE) |
| 69 | +endfunction() |
| 70 | + |
| 71 | + |
| 72 | +# Abuses knowledge of Catch2's JSON reporter output for listing tests to |
| 73 | +# split JSON array of the test listings into a CMake list of strings, |
| 74 | +# with each element being the JSON string of one array entry. |
| 75 | +# |
| 76 | +# This avoids the terrible quadratic running time of using CMake's JSON |
| 77 | +# support to parse the JSON reporter output "properly", where the whole |
| 78 | +# JSON array of tests is parsed again for every element. Instead, we can |
| 79 | +# use the CMake's API to only parse the individual test's objects, which |
| 80 | +# are usually small and only have to be reparsed fixed number of times |
| 81 | +# (once for test names, once for labels). |
| 82 | +# |
| 83 | +# We process the string representing the JSON array by splitting it on |
| 84 | +# `}<ws>*,<ws>*{` and then checking for each chunk whether it is a valid |
| 85 | +# JSON object representing Catch2's test. If not (e.g. because we split |
| 86 | +# on the presence of `}<ws>*,<ws>*{` inside a test name), then we append |
| 87 | +# the next chunk to the current one and check again. And again, until |
| 88 | +# we get back to a valid JSON. |
| 89 | +# |
| 90 | +# Note that to support passing the object strings back from the function, |
| 91 | +# they will still contain the placeholders from `get_placeholder_bytes` |
| 92 | +# and need to be unescaped before further processing (e.g. sending them |
| 93 | +# into CMake's JSON parsing API). |
| 94 | +function(split_json_array json_array_var out_var) |
| 95 | + # We have to pass the input by var name to avoid CMake processing |
| 96 | + # the input as an arg. |
| 97 | + set(json_in "${${json_array_var}}") |
| 98 | + |
| 99 | + # Strip the array brackets at the start and end of the JSON array. |
| 100 | + # Must happen before we escape the other [] instances below from the |
| 101 | + # actual array data. |
| 102 | + string(REGEX REPLACE "^[ \t\r\n]*\\[" "" json_in "${json_in}") |
| 103 | + string(REGEX REPLACE "\\][ \t\r\n]*$" "" json_in "${json_in}") |
| 104 | + |
| 105 | + magic_escape_chars(json_in) |
| 106 | + |
| 107 | + # We need to keep the whitespace around comma around, so that if we |
| 108 | + # split inside the test object, we can reconstruct it losslessly. |
| 109 | + get_placeholder_bytes(_semicolon _boundary_marker _open_bracket _close_bracket) |
| 110 | + string(REGEX REPLACE "(}[ \t\r\n]*)[,]([ \t\r\n]*{)" "\\1${_boundary_marker}\\2" json_in "${json_in}") |
| 111 | + |
| 112 | + # We escaped all list separators above, so now we can turn the JSON |
| 113 | + # string into a CMake list of fragments in single pass. |
| 114 | + string(REPLACE "${_boundary_marker}" ";" fragments "${json_in}") |
| 115 | + |
| 116 | + # And now we have to reconstruct the actual JSON structure from fragments. |
| 117 | + set(array_elements "") |
| 118 | + set(accumulator "") |
| 119 | + foreach(next_fragment IN LISTS fragments) |
| 120 | + if(accumulator) |
| 121 | + set(accumulator "${accumulator},${next_fragment}") |
| 122 | + else() |
| 123 | + set(accumulator "${next_fragment}") |
| 124 | + endif() |
| 125 | + |
| 126 | + # Because the fragments (might) contain invalid JSON characters due |
| 127 | + # to escaping, we have to unescape it before checking if we can parse it. |
| 128 | + set(maybe_json "${accumulator}") |
| 129 | + magic_unescape_chars(maybe_json) |
| 130 | + string(JSON unused ERROR_VARIABLE err GET "${maybe_json}" "name") |
| 131 | + if(err STREQUAL "NOTFOUND") |
| 132 | + list(APPEND array_elements "${accumulator}") |
| 133 | + set(accumulator "") |
| 134 | + endif() |
| 135 | + endforeach() |
| 136 | + |
| 137 | + set(${out_var} "${array_elements}" PARENT_SCOPE) |
| 138 | +endfunction() |
| 139 | + |
4 | 140 | # TBD: Further possible optimization is that most arguments for per-test |
5 | 141 | # `prepare_command` call are constant across one invocation of |
6 | 142 | # `catch_discover_tests`, and thus need checking and escaping only |
@@ -66,7 +202,6 @@ function(make_temp_file_path OUT_VARIABLE FALLBACK_PATH) |
66 | 202 | endfunction() |
67 | 203 |
|
68 | 204 | function(catch_discover_tests_impl) |
69 | | - |
70 | 205 | cmake_parse_arguments( |
71 | 206 | "" |
72 | 207 | "" |
@@ -147,9 +282,10 @@ function(catch_discover_tests_impl) |
147 | 282 | ) |
148 | 283 | endif() |
149 | 284 |
|
150 | | - # Read the JSON output back from the output file (and then get rid of the file) |
| 285 | + # Read the JSON output back from the output file and validate it. |
151 | 286 | file(READ ${listing_output_path} listing_output) |
152 | 287 | file(REMOVE ${listing_output_path}) |
| 288 | + validate_input_noescapes(listing_output) |
153 | 289 |
|
154 | 290 | # Prepare reporter |
155 | 291 | if(reporter) |
@@ -205,36 +341,35 @@ function(catch_discover_tests_impl) |
205 | 341 | message(FATAL_ERROR "Unsupported catch output version: '${version}'") |
206 | 342 | endif() |
207 | 343 |
|
208 | | - # Speed-up reparsing by cutting away unneeded parts of JSON. |
| 344 | + # Extract just the JSON array with tests and then split them into |
| 345 | + # individual objects. |
209 | 346 | string(JSON test_listing GET "${listing_output}" "listings" "tests") |
210 | | - string(JSON num_tests LENGTH "${test_listing}") |
| 347 | + split_json_array(test_listing tests) |
211 | 348 |
|
212 | 349 | # Exit early if no tests are detected |
213 | | - if(num_tests STREQUAL "0") |
| 350 | + if(NOT tests) |
214 | 351 | file(WRITE "${_CTEST_FILE}" "") |
215 | 352 | return() |
216 | 353 | endif() |
217 | 354 |
|
218 | | - # CMake's foreach-RANGE is inclusive, so we have to subtract 1 |
219 | | - math(EXPR num_tests "${num_tests} - 1") |
220 | | - |
221 | | - foreach(idx RANGE ${num_tests}) |
222 | | - string(LENGTH "${script}" script_len) |
| 355 | + # Each element in the tests is JSON-string representing one test object. |
| 356 | + # We have to parse it and then turn it into CTest script commands. |
| 357 | + foreach(single_test IN LISTS tests) |
223 | 358 | # Because appending to the same string in CMake has quadratic runtime, |
224 | 359 | # we flush the script into the file periodically to avoid the worst case. |
| 360 | + string(LENGTH "${script}" script_len) |
225 | 361 | if (script_len GREATER _WriteToFileThreshold) |
226 | 362 | file(APPEND "${_CTEST_FILE}" "${script}") |
227 | 363 | set(script "") |
228 | 364 | endif() |
229 | 365 |
|
230 | | - |
| 366 | + # The elements are still escaped and contain JSON-invalid characters, |
| 367 | + # they have to be unescaped before parsing them as JSON. |
| 368 | + magic_unescape_chars(single_test) |
231 | 369 | if(add_tags) |
232 | | - string(JSON single_test GET "${test_listing}" ${idx}) |
233 | 370 | string(JSON test_tags GET "${single_test}" "tags") |
234 | | - string(JSON plain_name GET "${single_test}" "name") |
235 | | - else() |
236 | | - string(JSON plain_name GET "${test_listing}" ${idx} "name") |
237 | 371 | endif() |
| 372 | + string(JSON plain_name GET "${single_test}" "name") |
238 | 373 |
|
239 | 374 | # Escape characters in test case names that would be parsed by Catch2 |
240 | 375 | # Note that the \ escaping must happen FIRST! Do not change the order. |
|
0 commit comments