string(JSON): Add ARRAY_SPLIT mode for linear array iteration

Iterating a JSON array with GET re-parses the entire string on every
call, so walking N elements is O(N^2).  ARRAY_SPLIT parses the array
once and returns its elements as a CMake list, letting each element be
queried individually in linear total time.

Each element is re-serialized as compact JSON and encoded to survive
CMake list parsing: '[' and ']' inside strings are emitted as \u005B
and \u005D, and ';' is escaped as '\;'.  Every element therefore stays
valid, re-queryable JSON.

Fixes: #27985
This commit is contained in:
Daksh Mamodiya
2026-08-07 17:45:50 +02:00
parent 85e06e3791
commit 7cca8540ca
9 changed files with 322 additions and 7 deletions
+36
View File
@@ -48,6 +48,9 @@ Synopsis
string(JSON <out-var> [ERROR_VARIABLE <error-var>]
{`GET <JSON-GET_>`__ | `GET_RAW <JSON-GET-RAW_>`__ | `TYPE <JSON-TYPE_>`__ | `LENGTH <JSON-LENGTH_>`__}
<json-string> [<member|index> ...])
string(JSON <out-var> [ERROR_VARIABLE <error-var>]
`ARRAY_SPLIT <JSON-ARRAY_SPLIT_>`__
<json-string> [<member|index> ...])
string(JSON <out-var> [ERROR_VARIABLE <error-var>]
`REMOVE <JSON-REMOVE_>`__
<json-string> <member|index> [<member|index> ...])
@@ -607,6 +610,39 @@ string is passed as a single argument even if it contains semicolons.
given by the list of ``<member|index>`` arguments.
Requires an element of array or object type.
.. signature::
string(JSON <out-var> [ERROR_VARIABLE <error-variable>]
ARRAY_SPLIT <json-string> [<member|index> ...])
:target: JSON-ARRAY_SPLIT
.. versionadded:: 4.5
Split the array in ``<json-string>`` at the location given by the list of
``<member|index>`` arguments into a :ref:`semicolon-separated list <CMake
Language Lists>`, one entry per array element. Requires an element of array
type.
This enables iterating an array with a single parse of ``<json-string>``,
instead of re-parsing the whole string for each element:
.. code-block:: cmake
string(JSON elements ARRAY_SPLIT "${json}")
foreach(element IN LISTS elements)
string(JSON value GET "${element}" some_member)
endforeach()
Each entry is the corresponding element re-serialized as compact JSON. It is
*semantically* equal to the source element (as compared by
:cref:`EQUAL <JSON-EQUAL_>`), but not necessarily byte-for-byte identical:
insignificant whitespace is removed and object members may be reordered.
The result is a regular CMake list and should be consumed with list-aware
constructs such as :command:`foreach(IN LISTS) <foreach>` or the
:command:`list` command. Elements may contain characters that are encoded to
keep the list well-formed, so operating on the raw ``<out-var>`` value with
plain string operations may expose that internal encoding.
.. signature::
string(JSON <out-var> [ERROR_VARIABLE <error-variable>]
REMOVE <json-string> <member|index> [<member|index> ...])
@@ -0,0 +1,7 @@
string-json-array-split
-----------------------
* The :command:`string(JSON)` command gained an ``ARRAY_SPLIT`` mode that
splits a JSON array into a list of its elements with a single parse,
enabling linear-time array iteration instead of re-parsing the whole
JSON string for each element.
+60 -5
View File
@@ -926,6 +926,41 @@ std::string WriteJson(Json::Value const& value)
return Json::writeString(writer, value);
}
// Rewrite a compact JSON value so it survives CMake's list grammar as one
// element. CMake list parsing splits on ';' only at bracket-nesting depth
// zero and un-escapes only '\;', never '[' or ']'. So inside string tokens
// emit '[' and ']' as the JSON escapes \u005B and \u005D (the list nesting
// counter never sees a literal bracket), and escape every ';' as '\;'. Both
// stay valid JSON that string(JSON) decodes back to the original characters.
std::string EncodeJsonListElement(std::string const& element)
{
std::string result;
result.reserve(element.size());
bool inString = false;
bool escaped = false;
for (char const c : element) {
if (escaped) {
result += c;
escaped = false;
} else if (c == '\\') {
escaped = true;
result += c;
} else if (c == '"') {
inString = !inString;
result += c;
} else if (inString && c == '[') {
result += "\\u005B";
} else if (inString && c == ']') {
result += "\\u005D";
} else if (c == ';') {
result += "\\;";
} else {
result += c;
}
}
return result;
}
bool JsonPartialMatch(Json::Value const& pattern, Json::Value const& actual)
{
if (pattern.type() != actual.type()) {
@@ -1001,13 +1036,13 @@ bool HandleJSONCommand(std::vector<std::string> const& arguments,
auto const& mode = args.PopFront("missing mode argument"_s);
if (mode != "GET"_s && mode != "GET_RAW"_s && mode != "TYPE"_s &&
mode != "MEMBER"_s && mode != "LENGTH"_s && mode != "REMOVE"_s &&
mode != "SET"_s && mode != "EQUAL"_s && mode != "STRING_ENCODE"_s &&
mode != "PARTIAL_EQUAL"_s) {
mode != "MEMBER"_s && mode != "LENGTH"_s && mode != "ARRAY_SPLIT"_s &&
mode != "REMOVE"_s && mode != "SET"_s && mode != "EQUAL"_s &&
mode != "STRING_ENCODE"_s && mode != "PARTIAL_EQUAL"_s) {
throw json_error(cmStrCat(
"got an invalid mode '"_s, mode,
"', expected one of GET, GET_RAW, TYPE, MEMBER, LENGTH, REMOVE, SET, "
" EQUAL, PARTIAL_EQUAL, STRING_ENCODE"_s));
"', expected one of GET, GET_RAW, TYPE, MEMBER, LENGTH, ARRAY_SPLIT, "
"REMOVE, SET, EQUAL, PARTIAL_EQUAL, STRING_ENCODE"_s));
}
auto const& jsonstr = args.PopFront("missing json string argument"_s);
@@ -1064,6 +1099,26 @@ bool HandleJSONCommand(std::vector<std::string> const& arguments,
cmAlphaNum sizeStr{ value.size() };
makefile.AddDefinition(*outputVariable, sizeStr.View());
} else if (mode == "ARRAY_SPLIT"_s) {
auto const& value = ResolvePath(json, args);
if (!value.isArray()) {
throw json_error(cmStrCat("ARRAY_SPLIT needs to be called with an "
"element of type ARRAY, got "_s,
JsonTypeToString(value.type())),
args);
}
Json::StreamWriterBuilder writer;
writer["indentation"] = "";
writer["commentStyle"] = "None";
std::vector<std::string> elements;
elements.reserve(value.size());
for (Json::Value const& element : value) {
elements.push_back(
EncodeJsonListElement(Json::writeString(writer, element)));
}
makefile.AddDefinition(*outputVariable, cmList::to_string(elements));
} else if (mode == "REMOVE"_s) {
auto const& toRemove =
args.PopBack("missing member or index to remove"_s);
+209
View File
@@ -711,3 +711,212 @@ string(JSON result PARTIAL_EQUAL
if(result)
message(SEND_ERROR "Expected OFF got ${result} for duplicate array mismatch in object")
endif()
# Test ARRAY_SPLIT
# Split the top-level array in <json>, assert the element count matches LENGTH
# and each element is semantically equal to the corresponding source element,
# then return the split list in <out-var> (if given). Elements are always read
# back through a list-aware reader so the encoded list is exercised end-to-end.
function(assert_array_split json)
string(JSON _split ERROR_VARIABLE _err ARRAY_SPLIT "${json}")
if(_err)
message(SEND_ERROR "Unexpected ARRAY_SPLIT error: ${_err}\n for: ${json}")
return()
endif()
string(JSON _len LENGTH "${json}")
list(LENGTH _split _n)
if(NOT _n EQUAL _len)
message(SEND_ERROR "ARRAY_SPLIT gave ${_n} elements, expected ${_len}\n for: ${json}")
return()
endif()
set(_i 0)
foreach(_element IN LISTS _split)
string(JSON _src GET_RAW "${json}" ${_i})
string(JSON _eq ERROR_VARIABLE _eqerr EQUAL "${_element}" "${_src}")
if(_eqerr)
message(SEND_ERROR "ARRAY_SPLIT element ${_i} is not valid JSON: ${_eqerr}\n element: ${_element}")
elseif(NOT _eq)
message(SEND_ERROR "ARRAY_SPLIT element ${_i}\n ${_element}\n not equal to source\n ${_src}")
endif()
math(EXPR _i "${_i} + 1")
endforeach()
if(ARGC GREATER 1)
set(${ARGV1} "${_split}" PARENT_SCOPE)
endif()
endfunction()
# Basic array of objects: cardinality, order, and member access.
assert_array_split([=[
[
{ "A": 1, "B": "one" },
{ "A": 2, "B": "two" },
{ "A": 3, "B": "three" }
]
]=] elements)
list(GET elements 0 element0)
string(JSON a GET "${element0}" A)
assert_strequal("${a}" 1)
string(JSON b GET "${element0}" B)
assert_strequal("${b}" one)
list(GET elements 2 element2)
string(JSON b GET "${element2}" B)
assert_strequal("${b}" three)
# Empty array, top-level and via a path.
assert_array_split("[]" empty)
list(LENGTH empty emptyLen)
assert_strequal("${emptyLen}" 0)
string(JSON pathEmpty ERROR_VARIABLE error ARRAY_SPLIT [=[{"data":[]}]=] data)
if(error)
message(SEND_ERROR "Unexpected error: ${error}")
endif()
list(LENGTH pathEmpty pathEmptyLen)
assert_strequal("${pathEmptyLen}" 0)
# Path-located array.
set(doc [=[{ "outer": { "data": [10, 20, 30] } }]=])
string(JSON pathSplit ERROR_VARIABLE error ARRAY_SPLIT "${doc}" outer data)
if(error)
message(SEND_ERROR "Unexpected error: ${error}")
endif()
list(LENGTH pathSplit pathSplitLen)
assert_strequal("${pathSplitLen}" 3)
list(GET pathSplit 1 pathElement1)
assert_strequal("${pathElement1}" 20)
# Scalars: cardinality preserved (no empty-element collapsing) and types kept.
assert_array_split([=[[null, false, 0, ""]]=] scalars)
list(LENGTH scalars scalarsLen)
assert_strequal("${scalarsLen}" 4)
list(GET scalars 0 scalar0)
string(JSON scalarType TYPE "${scalar0}")
assert_strequal("${scalarType}" NULL)
list(GET scalars 1 scalar1)
string(JSON scalarType TYPE "${scalar1}")
assert_strequal("${scalarType}" BOOLEAN)
list(GET scalars 2 scalar2)
string(JSON scalarType TYPE "${scalar2}")
assert_strequal("${scalarType}" NUMBER)
list(GET scalars 3 scalar3)
string(JSON scalarType TYPE "${scalar3}")
assert_strequal("${scalarType}" STRING)
# Numbers: integer, real, exponent, and large integer round-trip.
assert_array_split([=[[1, 1000.0, 1e3, 1234567890]]=])
# Nested arrays and objects: structural brackets left intact.
assert_array_split([=[[[1,2],[3,4]]]=] nested)
list(GET nested 0 nested0)
string(JSON nestedType TYPE "${nested0}")
assert_strequal("${nestedType}" ARRAY)
string(JSON nestedVal GET "${nested0}" 1)
assert_strequal("${nestedVal}" 2)
assert_array_split([=[[{"x":1},{"y":2}]]=] objects)
list(GET objects 1 objects1)
string(JSON objectsType TYPE "${objects1}")
assert_strequal("${objectsType}" OBJECT)
string(JSON objectsVal GET "${objects1}" y)
assert_strequal("${objectsVal}" 2)
# Whitespace and object-member-order canonicalization compare EQUAL.
assert_array_split([=[[ { "b" : 1 , "a" : 2 } ]]=])
# Adversarial: ';' inside a string value must not split the element.
assert_array_split([=[[{"cmd":"a;b"},{"d":4}]]=] semicolon)
list(LENGTH semicolon semicolonLen)
assert_strequal("${semicolonLen}" 2)
list(GET semicolon 0 semicolon0)
string(JSON semicolonVal GET "${semicolon0}" cmd)
assert_strequal("${semicolonVal}" "a;b")
# Adversarial: an unbalanced '[' or ']' inside a string must not merge elements.
assert_array_split([=[[{"a":"["},{"b":2}]]=] openBracket)
list(LENGTH openBracket openBracketLen)
assert_strequal("${openBracketLen}" 2)
list(GET openBracket 0 openBracket0)
string(JSON openBracketVal GET "${openBracket0}" a)
assert_strequal("${openBracketVal}" "[")
assert_array_split([=[[{"a":"]"},{"b":2}]]=] closeBracket)
list(LENGTH closeBracket closeBracketLen)
assert_strequal("${closeBracketLen}" 2)
list(GET closeBracket 0 closeBracket0)
string(JSON closeBracketVal GET "${closeBracket0}" a)
assert_strequal("${closeBracketVal}" "]")
# Adversarial: several brackets in one string value.
assert_array_split([=[[{"s":"[[]]["},{"z":0}]]=] brackets)
list(GET brackets 0 brackets0)
string(JSON bracketsVal GET "${brackets0}" s)
assert_strequal("${bracketsVal}" "[[]][")
# Adversarial: a bracket in an object *key* (keys are strings too).
assert_array_split([=[[{"[k]":1},{"y":2}]]=] keyBracket)
list(GET keyBracket 0 keyBracket0)
string(JSON keyBracketVal GET "${keyBracket0}" "[k]")
assert_strequal("${keyBracketVal}" 1)
string(JSON keyBracketName MEMBER "${keyBracket0}" 0)
assert_strequal("${keyBracketName}" "[k]")
# Adversarial: ';' adjacent to backslashes (value is a\;b).
assert_array_split([==[[{"cmd":"a\\;b"},{"d":4}]]==] semiBackslash)
list(GET semiBackslash 0 semiBackslash0)
string(JSON semiBackslashVal GET "${semiBackslash0}" cmd)
assert_strequal("${semiBackslashVal}" [==[a\;b]==])
# Adversarial: an escaped quote before a bracket (value is x"[y).
assert_array_split([==[[{"a":"x\"[y"},{"b":2}]]==] quoteBracket)
list(GET quoteBracket 0 quoteBracket0)
string(JSON quoteBracketVal GET "${quoteBracket0}" a)
assert_strequal("${quoteBracketVal}" [==[x"[y]==])
# Adversarial: a string value ending in an escaped backslash (value is x\).
assert_array_split([==[[{"a":"x\\"},{"b":2}]]==] endBackslash)
list(GET endBackslash 0 endBackslash0)
string(JSON endBackslashVal GET "${endBackslash0}" a)
assert_strequal("${endBackslashVal}" [==[x\]==])
# Adversarial: literal "\u005B" text (six characters) must stay unchanged.
assert_array_split([==[[{"a":"\\u005B"},{"b":2}]]==] literalEscape)
list(GET literalEscape 0 literalEscape0)
string(JSON literalEscapeVal GET "${literalEscape0}" a)
assert_strequal("${literalEscapeVal}" [==[\u005B]==])
# Unicode survives compact re-serialization (reuse the unicode fixture).
file(READ ${CMAKE_CURRENT_LIST_DIR}/json/unicode.json unicode)
assert_array_split("[${unicode}]" unicodeSplit)
list(GET unicodeSplit 0 unicodeElement)
string(JSON unicodeVal GET "${unicodeElement}" datalinkescape)
string(JSON unicodeSrc GET "${unicode}" datalinkescape)
assert_strequal("${unicodeVal}" "${unicodeSrc}")
# Error handling with ERROR_VARIABLE: non-array target must mirror the NOTFOUND
# form that LENGTH produces for the same input, and report an ARRAY message.
# Non-array (string) at top level.
string(JSON asResult ERROR_VARIABLE asError ARRAY_SPLIT "\"text\"")
string(JSON lenResult ERROR_VARIABLE lenError LENGTH "\"text\"")
assert_strequal("${asResult}" "${lenResult}")
assert_strequal("${asError}" "ARRAY_SPLIT needs to be called with an element of type ARRAY, got STRING")
# Object at top level is also rejected (LENGTH would accept it, so no cross-check
# on the message, but the NOTFOUND form matches the string case above).
string(JSON objResult ERROR_VARIABLE objError ARRAY_SPLIT "{}")
assert_strequal("${objResult}" "${asResult}")
assert_strequal("${objError}" "ARRAY_SPLIT needs to be called with an element of type ARRAY, got OBJECT")
# Non-array (string) at a path.
string(JSON pathResult ERROR_VARIABLE pathError ARRAY_SPLIT [=[{"data":"text"}]=] data)
string(JSON pathLenResult ERROR_VARIABLE pathLenError LENGTH [=[{"data":"text"}]=] data)
assert_strequal("${pathResult}" "${pathLenResult}")
assert_strequal("${pathResult}" "data-NOTFOUND")
assert_strequal("${pathError}" "ARRAY_SPLIT needs to be called with an element of type ARRAY, got STRING")
# Non-existent path mirrors LENGTH's <path>-NOTFOUND and reports an error.
string(JSON missResult ERROR_VARIABLE missError ARRAY_SPLIT [=[{"a":1}]=] b)
string(JSON missLenResult ERROR_VARIABLE missLenError LENGTH [=[{"a":1}]=] b)
assert_strequal("${missResult}" "${missLenResult}")
assert_strequal("${missResult}" "b-NOTFOUND")
if(NOT missError)
message(SEND_ERROR "Expected an error for a non-existent ARRAY_SPLIT path")
endif()
@@ -0,0 +1 @@
1
@@ -0,0 +1,5 @@
CMake Error at JSONArraySplitNoArray\.cmake:1 \(string\):
string sub-command JSON ARRAY_SPLIT needs to be called with an element of
type ARRAY, got NUMBER\.
Call Stack \(most recent call first\):
CMakeLists\.txt:[0-9]+ \(include\)
@@ -0,0 +1 @@
string(JSON var ARRAY_SPLIT "5")
@@ -1,6 +1,6 @@
CMake Error at JSONWrongMode\.cmake:1 \(string\):
string sub-command JSON got an invalid mode 'FOO', expected one of GET,
GET_RAW, TYPE, MEMBER, LENGTH, REMOVE, SET, EQUAL, PARTIAL_EQUAL,
STRING_ENCODE\.
GET_RAW, TYPE, MEMBER, LENGTH, ARRAY_SPLIT, REMOVE, SET, EQUAL,
PARTIAL_EQUAL, STRING_ENCODE\.
Call Stack \(most recent call first\):
CMakeLists\.txt:[0-9]+ \(include\)
+1
View File
@@ -7,6 +7,7 @@ run_cmake(JSONWrongMode)
run_cmake(JSONOneArg)
run_cmake(JSONNoArgs)
run_cmake(JSONBadJson)
run_cmake(JSONArraySplitNoArray)
run_cmake(Append)
run_cmake(AppendNoArgs)