A port of the canonical TypeScript implementation. Behaviour is defined by TypeScript and pinned by the shared corpus; this port follows it. This guide is the in-depth companion to
README.md(quick start + signature reference) and the language-neutral../DOCS.md.
Four parts, each with a different job:
- Tutorial — build and learn the API hands-on.
- How-to guides — recipes for specific tasks.
- Reference — signatures live in
README.md; this section adds the C++-specific semantics and types. - Explanation — the model, the port's place in it, and C++-specific behaviour.
Then: Build, test, extend.
The library is header-only and lives in three files under
src/:
value.hpp— theValuetype (astd::varianttagged union), the in-treeOrderedMap,Sentinel, type bit-flags, mode constants, and predicates.value_io.hpp— JSON text I/O via a hand-written recursive-descent parser (parse_json/dump_json); no third-party deps.voxgig_struct.hpp— the main API.
There is nothing to compile to use it — just include the headers and target
C++17. Include value_io.hpp (it transitively pulls in
value.hpp and voxgig_struct.hpp) so the JSON bridge parse_json /
dump_json is in scope alongside the main API:
#include "value_io.hpp"
using namespace voxgig::structlib;(voxgig_struct.hpp on its own gives you the API but not parse_json —
that lives in value_io.hpp.)
Working from a clone (you'll do this to run the corpus or extend the port):
cd cpp
make build # smoke + corpus driver (== make test)make test compiles the test harness, which needs the header-only
nlohmann/json on the include path
(e.g. /usr/include) to load the corpus. The library proper does not
use it. Override the location with make JSON_INC=/path test; make inspect prints the compiler version and the located header.
#include "value_io.hpp"
using namespace voxgig::structlib;
int main() {
Value config = merge_v(parse_json(R"([
{"db": {"host": "localhost", "port": 5432}, "debug": false},
{"db": {"host": "db.internal"}, "debug": true}
])"));
getpath_v(config, Value("db.host")); // "db.internal"
getpath_v(config, Value("db.port")); // 5432 (survived the deep merge)
}Note the _v suffix on merge_v / getpath_v — see
Casing.
getpath_v takes the store first, then a dot-path, and reads the deep value:
getpath_v(parse_json(R"({"a":{"b":{"c":42}}})"), Value("a.b.c")); // 42Each call has the same meaning in every port; only the spelling changes.
Read ../DOCS.md for the full
language-neutral walkthrough. The C++ flavour:
// Reshape by example — the spec mirrors the output you want.
transform(parse_json(R"({"user":{"first":"Ada","last":"Lovelace"},"age":36})"),
parse_json(R"({"name":"`user.first`","surname":"`user.last`","years":"`age`"})"));
// {"name":"Ada","surname":"Lovelace","years":36}
// Validate by example — leaves are type checkers; throws on mismatch.
validate(parse_json(R"({"name":"Ada","age":36})"),
parse_json(R"({"name":"`$STRING`","age":"`$INTEGER`"})"));
// Select children by query — each match tagged with its $KEY.
select(parse_json(R"({"a":{"age":30},"b":{"age":25}})"), parse_json(R"({"age":30})"));
// [ {"age":30,"$KEY":"a"} ]A transform command like $EACH appears in value position — the first
element of a list ["$EACH", path, subspec] — mapping the sub-spec over
every entry at path:
transform(parse_json(R"({"v":1,"a":[{"q":13},{"q":23}]})"),
parse_json(R"({"x":{"y":["`$EACH`","a",{"q":"`$COPY`","r":"`.q`","p":"`...v`"}]}})"));
// {"x":{"y":[{"q":13,"r":13,"p":1},{"q":23,"r":23,"p":1}]}}walk_v takes WalkApply callbacks — std::functions receiving
(key, val, parent, path) and returning the replacement value:
walk_v(tree, nullptr, [](const Value& key, const Value& val,
const Value& parent, const std::vector<std::string>& path) {
return val.is_null() ? Value("DEFAULT") : val;
});Pass nullptr for an unused before/after slot.
jm builds a map from alternating key/value args; jt builds a list:
Value m = jm({"host", Value("localhost"), "port", Value(5432)});
Value l = jt({Value(1), Value(2), Value(3)});jsonify pretty-prints by default (2-space indent, insertion-ordered keys);
pass 0 for the compact form. stringify is the quote-light human form, for
logs.
jsonify(parse_json(R"({"a":1})"));
// {
// "a": 1
// }jsonify(parse_json(R"({"a":1,"b":[2,3]})"));
// {
// "a": 1,
// "b": [
// 2,
// 3
// ]
// }jsonify(parse_json(R"({"a":1,"b":2})"), 0); // {"a":1,"b":2}stringify(parse_json(R"({"a":1,"b":[2,3]})")); // {a:1,b:[2,3]}stringify(Value("verylongstring"), 5); // ve...parse_json / dump_json in value_io.hpp are the
text bridge; jsonify is the canonical printer they share.
Register an Injector (std::function) and reference it by name in the
spec. The function may return the SKIP() / DELETE_V() sentinels to omit
or remove the current key.
A transform command must sit in value position. Putting $APPLY
directly under a map (in key position) is an error:
transform(parse_json("{}"), parse_json(R"({"x":"`$APPLY`"})"));
// throws: $APPLY: invalid placement in parent map, expected: list.For the full recipe set (merge configs, rename fields, $EACH, $MERGE,
$FORMAT, $ONE, $EXACT, …) see the language-neutral
How-to guides — the spec syntax is identical;
only the host literals differ.
The canonical public surface (48 names) is the export { … } block in
typescript/src/StructUtility.ts;
../tools/check_parity.py checks every port against it. The C++
implementations are declared near the top of
src/voxgig_struct.hpp (forward declarations)
and defined below; everything public lives in namespace
voxgig::structlib.
C++-specific points the signatures don't show:
Valueis the data model. It is astd::variantover undefined (std::monostate), JSON null (std::nullptr_t),bool,int64_t,double,std::string,shared_ptr<List>,shared_ptr<Map>,Injector,Modify, andconst Sentinel*.undefinedandnullare distinct storage slots — see null vs absent.- Most functions take
const Value&and returnValue. Optional arguments default toValue::undef().keysofreturnsstd::vector<std::string>;itemsreturnsstd::vector<Value>of[key, val]pairs;selectreturnsstd::vector<Value>. - Callbacks are
std::function.WalkApplyis the walk callback type;Injector/Modifyare the transform/inject hook types (defined invalue.hpp). Passnullptrto skip an optional slot. - Type flags combine bitwise.
typify(Value("hi"))isT_scalar | T_string; test with0 != (T_string & t).typifyof undefined isT_noval;typify(Value(nullptr))isT_scalar | T_null.typename_str(int)(andtypename_str(const Value&)) names the dominant flag. - Sentinels are pointer-identity singletons.
SKIP()/DELETE_V()returnValues wrappingconst Sentinel*, so==identity survivesclone. (DELETE_Vcarries the_Vsuffix becauseDELETEcollides with a common macro/keyword expectation.)
Names are lowercase canonical, with a handful of unavoidable C++ renames
(../tools/check_parity.py strips the suffixes for this port):
| Canonical | C++ name | Why |
|---|---|---|
walk |
walk_v |
the _v ("value-style") suffix disambiguates from a header-internal helper of the same root name |
merge |
merge_v |
same |
getpath |
getpath_v |
same |
setpath |
setpath_v |
same |
typename |
typename_str |
typename is a reserved C++ keyword |
Everything else keeps its canonical spelling (getprop, setprop,
inject, transform, validate, select, isnode, ismap, …).
Behaviour is defined by the canonical TypeScript and pinned by the shared
corpus in ../build/test/. When this port disagrees with
the corpus, the port is wrong — fix it here, never edit the corpus. A
genuine behaviour change starts in TypeScript and flows out to every port
(see ../AGENTS.md).
C++ here has both, kept distinct — the
Group A/B rule in language-neutral
form (full text in ../UNDEF_SPEC.md):
- Absent is
Value::undef()(std::monostate).getpropon a missing key returns thealt; Group A readers (getprop,getelem,haskey,isempty,isnode) treat a storednullas absent too. - null is the JSON null scalar (
std::nullptr_t);typifyisT_scalar | T_null, and Group B value-processors (clone,merge_v,walk_v,setprop, …) preserve it literally.
This distinction is the single most common source of port bugs; check it first when a read/merge/clone case fails.
walk_v, merge_v, inject, and setpath_v rely on lists and maps being
shared by reference so a mutation through one handle is visible to all. The
port gets this from shared_ptr<List> / shared_ptr<Map> inside Value —
no ListRef wrapper is needed.
Map key order must match insertion order (the inject machinery partitions
non-$ keys before $ keys, and jsonify emits in order). C++'s stdlib
has no insertion-ordered map, so the port ships the in-tree OrderedMap
(vector + index) in value.hpp. Never substitute an
unordered map.
The library proper uses only the C++17 standard library plus its own
in-tree helpers: the OrderedMap, the hand-written JSON parser in
value_io.hpp, and jsonify as the printer.
nlohmann/json is a test-harness-only build requirement (corpus
loading); the shipped headers never include it.
The regex layer wraps the C++ standard <regex> (ECMAScript dialect)
behind the uniform six-function API: re_compile, re_test, re_find,
re_find_all, re_replace, re_escape. Stay inside the RE2 subset —
std::regex allows backreferences and lookaround, but those don't port.
Two sharp edges: libstdc++'s <regex> has the worst-in-class catastrophic
backtracking, and zero-width re_replace("a*", "abc", "X") returns
"XXbXcX" (the ECMA convention; Go's RE2 returns "XbXcX"). Details in
README.md → Regex and
../REGEX_PATHOLOGICAL.md.
cd cpp
make build # smoke + corpus driver (default target; == make test)
make smoke # just the smoke test
make corpus # just the corpus driver
make sanitize # corpus built + run under ASan + UBSan
make check_leak # corpus under valgrind (full leak check)
make lint # clang-tidy + clang-format --dry-run --Werror
make inspect # print g++ version and the located nlohmann/json headerTests live in tests/; the corpus driver
(tests/struct_corpus_test.cpp, via tests/runner.hpp) loads the shared
corpus from ../build/test/ and mirrors the reference
runner in ../typescript/test/runner.ts.
The header path for nlohmann/json defaults to /usr/include; override
with make JSON_INC=/path ….
To change behaviour: behaviour is canonical. Edit
../typescript/src/StructUtility.ts
and the corpus first, then port the change into the three headers here,
make test until green, run make lint, and re-run
python3 ../tools/check_parity.py. The full cross-port checklist is in
../AGENTS.md.