Source
stdlib/parsers/json/read.hpp
1
// Copyright (c) 2026 BigBrain LLC. MIT-licensed (see LICENSE).2
// Original work; see ACKNOWLEDGMENTS.md for the open-source ideas we build upon.3
#pragma once5
// cheatah::parsers::json — read<T>(): parse JSON DIRECTLY into a typed struct, no Node/variant DOM.6
//7
// struct Series { std::string symbol; std::vector<Ohlc> data; }; // + schema<> specializations8
// Series s;9
// if (json::read(text, s)) use(s);10
//11
// Each field is dispatched on its STATIC type (the concept set below), so a number lands in its12
// typed field via std::from_chars with no intermediate double, a string is copied (std::string) or13
// viewed (std::string_view), nested structs recurse on their schema, std::vector<T> reads a14
// variable-length array, std::array<T, N> reads a FIXED N-element array inline (no allocation), and15
// std::optional<T> reads JSON null as nullopt. Unknown keys are skipped. Validate is the same16
// compile-time switch as the DOM parser: with Validate=false every bounds/structure check is17
// `if constexpr`-removed for trusted input (UB on malformed input). No runtime polymorphism.18
//19
// The data-structure recursion (nested structs / vectors) is bounded by the SCHEMA depth, which is20
// fixed at compile time — not attacker-controlled — so natural recursion here cannot be overflowed21
// by deep input. (Skipping an unknown value of attacker-controlled depth uses detail::skip_value,22
// which is iterative.)24
#include <array>25
#include <cstddef>26
#include <cstring>27
#include <optional>28
#include <string>29
#include <string_view>30
#include <tuple>31
#include <type_traits>32
#include <utility>33
#include <vector>35
#include "cursor.hpp"36
#include "scan.hpp"37
#include "schema.hpp"39
namespace cheatah::parsers::json {41
namespace detail {43
template <class>44
struct is_vector : std::false_type {};45
template <class E, class A>46
struct is_vector<std::vector<E, A>> : std::true_type {};48
template <class>49
struct is_std_array : std::false_type {};50
template <class E, std::size_t N>51
struct is_std_array<std::array<E, N>> : std::true_type {};53
template <class>54
struct is_optional : std::false_type {};55
template <class E>56
struct is_optional<std::optional<E>> : std::true_type {};58
// Mutually-recursive workers (value -> array/object -> value). Declared first so they can call one59
// another regardless of definition order.60
//61
// read_value is FORCE-inlined: the per-instantiation call graph is acyclic (a type cannot contain62
// itself), so this is safe, and it matters — the optimizer's size heuristic otherwise outlines the63
// scalar instantiations (read_value<long>, read_value<string_view>), adding a function-call round64
// trip to every scalar member; inlining the whole hot path removes it.65
// @complexity O(value size) @alloc only what the field type owns @test JsonRead.Scalars66
template <bool Validate, class T>67
[[gnu::always_inline]] inline bool read_value(Cursor& c, T& out);68
template <bool Validate, class Vec>69
bool read_array(Cursor& c, Vec& out);70
template <bool Validate, class Arr>71
bool read_fixed_array(Cursor& c, Arr& out);72
template <bool Validate, class T, class... Fields>73
bool read_object(Cursor& c, T& out, const ObjectSchema<Fields...>& sch);75
// Read a JSON string body into an owned std::string (decoding escapes) or a zero-copy view (only76
// when unescaped — an escaped string has no contiguous source to view).77
// @complexity O(|string|) @alloc only for an owned std::string @test JsonRead.ViewVsOwned78
template <bool Validate, class Str>79
bool read_string(Cursor& c, Str& out) {80
if constexpr (Validate) {81
if (c.it == c.end || *c.it != '"') {82
return false;83
}84
}85
std::string_view raw;86
bool esc = false;87
if (!scan_string(c, raw, esc)) {88
return false;89
}90
if constexpr (std::is_same_v<Str, std::string_view>) {91
if (esc) {92
return false; // cannot view an escaped string; declare the field std::string instead93
}94
out = raw;95
return true;96
} else { // std::string (owned)97
if (!esc) {98
out.assign(raw);99
return true;100
}101
return decode_escapes(raw, out);102
}103
}105
template <bool Validate, class T>106
inline bool read_value(Cursor& c, T& out) {107
skip_ws(c);108
if constexpr (Validate) {109
if (c.it == c.end) {110
return false;111
}112
}113
if constexpr (std::is_same_v<T, bool>) {114
if (match(c, "true")) {115
out = true;116
return true;117
}118
if (match(c, "false")) {119
out = false;120
return true;121
}122
return false;123
} else if constexpr (std::is_arithmetic_v<T>) { // integral or floating (bool handled above)124
return parse_arithmetic(c, out);125
} else if constexpr (std::is_same_v<T, std::string> ||126
std::is_same_v<T, std::string_view>) {127
return read_string<Validate>(c, out);128
} else if constexpr (is_optional<T>::value) {129
if (match(c, "null")) {130
out.reset();131
return true;132
}133
return read_value<Validate>(c, out.emplace());134
} else if constexpr (is_std_array<T>::value) {135
return read_fixed_array<Validate>(c, out); // fixed N elements, inline (no allocation)136
} else if constexpr (is_vector<T>::value) {137
return read_array<Validate>(c, out);138
} else if constexpr (HasSchema<T>) {139
return read_object<Validate>(c, out, schema<T>);140
} else {141
static_assert(sizeof(T) == 0,142
"json::read: type is not a supported scalar/container and has no schema<T>");143
return false;144
}145
}147
// Read a variable-length JSON array into a std::vector, element by element, in place.148
// @complexity O(array size) @alloc vector growth (capacity reused via clear) @test JsonRead.Vectors149
template <bool Validate, class Vec>150
bool read_array(Cursor& c, Vec& out) {151
if constexpr (Validate) {152
if (c.it == c.end || *c.it != '[') {153
return false;154
}155
}156
++c.it; // skip '['157
skip_ws(c);158
if constexpr (Validate) {159
if (c.it == c.end) {160
return false;161
}162
}163
out.clear();164
if (*c.it == ']') {165
++c.it;166
return true; // empty array167
}168
for (;;) {169
// Construct the element in place and read straight into it — no temporary, no move.170
if (!read_value<Validate>(c, out.emplace_back())) {171
return false;172
}173
skip_ws(c);174
if constexpr (Validate) {175
if (c.it == c.end) {176
return false;177
}178
}179
const char sep = *c.it++;180
if (sep == ']') {181
return true;182
}183
if (sep != ',') {184
return false; // expected ',' or ']'185
}186
}187
}189
// Read a JSON array of EXACTLY N elements into a std::array<E, N> — fixed size, stored inline, so190
// it never allocates. A wrong element count (too few / too many) is a parse error under Validate.191
// @complexity O(N) @alloc none @test JsonRead.FixedArrays192
template <bool Validate, class Arr>193
bool read_fixed_array(Cursor& c, Arr& out) {194
constexpr std::size_t kSize = std::tuple_size_v<Arr>;195
if constexpr (Validate) {196
if (c.it == c.end || *c.it != '[') {197
return false;198
}199
}200
++c.it; // skip '['201
for (std::size_t i = 0; i < kSize; ++i) {202
if (!read_value<Validate>(c, out[i])) {203
return false;204
}205
skip_ws(c);206
if constexpr (Validate) {207
// every element but the last is followed by ',', the last by ']' — anything else is208
// the wrong arity or a missing separator.209
if (c.it == c.end || *c.it != (i + 1 == kSize ? ']' : ',')) {210
return false;211
}212
}213
++c.it; // consume the ',' (between elements) or the closing ']' (after the last)214
}215
if constexpr (kSize == 0) { // std::array<E, 0>: no elements, so still consume the ']'216
skip_ws(c);217
if constexpr (Validate) {218
if (c.it == c.end || *c.it != ']') {219
return false;220
}221
}222
++c.it;223
}224
return true;225
}227
// Match `key` against the schema's fields; read the value into the matching member, or skip it.228
// Read one complete `"key": value` member into the matching field of `out`. On entry the cursor is229
// at the opening quote of the key (or the whitespace before it on the general path).230
//231
// FAST PATH — predicted-literal match: `hint` names the field we EXPECT next (the one after the232
// previous match), because real JSON nearly always lists keys in schema order. We compare the233
// input bytes directly against that field's `"name":` literal — one short memcmp replaces the234
// whole key pipeline (quote scan, key compare, colon handling). In-order compact input takes this235
// path for every member. Anything else — whitespace inside the member syntax, out-of-order,236
// unknown, or escaped keys — leaves the cursor untouched and falls back to the general scan.237
// @complexity O(|key| + value) @alloc only a temp std::string when the key itself is escaped (rare); otherwise none of its own @test JsonRead.OutOfOrderKeys238
template <bool Validate, class T, class... Fields>239
bool read_one_member(Cursor& c, T& out, const ObjectSchema<Fields...>& sch, std::size_t& hint) {240
constexpr std::size_t kCount = sizeof...(Fields);241
bool ok = true;242
std::size_t matched = kCount;243
const auto read_into = [&](const auto& f, std::size_t index) {244
matched = index;245
ok = read_value<Validate>(c, out.*(f.ptr));246
};248
// ---- fast path: does the input start with the predicted field's `"name":` bytes? ----249
const auto try_predicted = [&](const auto& f, std::size_t index) {250
const std::string_view name = f.name;251
const char* const p = c.it;252
if (static_cast<std::size_t>(c.end - p) < name.size() + 3) {253
return false; // not enough bytes for `"name":` — let the general path decide254
}255
if (p[0] != '"' || p[name.size() + 1] != '"' || p[name.size() + 2] != ':' ||256
std::memcmp(p + 1, name.data(), name.size()) != 0) {257
return false; // not the predicted key (cursor untouched)258
}259
c.it = p + name.size() + 3; // step past `"name":` in one go260
read_into(f, index);261
return true;262
};263
const auto predicted = [&]<std::size_t... Is>(std::index_sequence<Is...>) {264
return (((Is == hint) && try_predicted(std::get<Is>(sch.fields), Is)) || ...);265
};267
if (!predicted(std::make_index_sequence<kCount>{})) {268
// ---- general path: scan the key, then match it against the fields ----269
if constexpr (Validate) {270
if (c.it == c.end || *c.it != '"') {271
return false; // key must be a string272
}273
}274
std::string_view key;275
bool esc = false;276
if (!scan_string(c, key, esc)) {277
return false;278
}279
std::string key_decoded; // only used when the key itself contains escapes (rare)280
if (esc) {281
if (!decode_escapes(key, key_decoded)) {282
return false;283
}284
key = key_decoded;285
}286
skip_ws(c);287
if constexpr (Validate) {288
if (c.it == c.end || *c.it != ':') {289
return false;290
}291
}292
++c.it; // skip ':'293
const auto try_field = [&](const auto& f, std::size_t index) {294
if (f.name != key) {295
return false; // not this field — keep looking296
}297
read_into(f, index);298
return true; // matched — stop the fold299
};300
// Search [hint, N) first, then wrap to [0, hint) — the `Is >= hint` guards on skipped301
// indices are just integer compares, and the `||` folds short-circuit at the first match.302
const auto search = [&]<std::size_t... Is>(std::index_sequence<Is...>) {303
return ((Is >= hint && try_field(std::get<Is>(sch.fields), Is)) || ...) ||304
((Is < hint && try_field(std::get<Is>(sch.fields), Is)) || ...);305
};306
if (!search(std::make_index_sequence<kCount>{})) {307
return skip_value(c); // a key not in the schema: discard its value308
}309
}310
if (!ok) {311
return false;312
}313
hint = (matched + 1 == kCount) ? 0 : matched + 1;314
return true;315
}317
// Read a JSON object member-by-member into the schema'd struct `out`.318
// @complexity O(object size) @alloc only what the field types own @test JsonRead.NestedStructs319
template <bool Validate, class T, class... Fields>320
bool read_object(Cursor& c, T& out, const ObjectSchema<Fields...>& sch) {321
if constexpr (Validate) {322
if (c.it == c.end || *c.it != '{') {323
return false;324
}325
}326
++c.it; // skip '{'327
skip_ws(c);328
if constexpr (Validate) {329
if (c.it == c.end) {330
return false;331
}332
}333
if (*c.it == '}') {334
++c.it;335
return true; // empty object336
}337
std::size_t hint = 0; // the field we expect NEXT (keys usually arrive in schema order)338
for (;;) {339
skip_ws(c);340
if (!read_one_member<Validate>(c, out, sch, hint)) { // `"key": value` (predicted or scanned)341
return false;342
}343
skip_ws(c);344
if constexpr (Validate) {345
if (c.it == c.end) {346
return false;347
}348
}349
const char sep = *c.it++;350
if (sep == '}') {351
return true;352
}353
if (sep != ',') {354
return false; // expected ',' or '}'355
}356
}357
}359
} // namespace detail361
/**362
* Parse `text` directly into `out` (a struct with a schema<>, a supported scalar, std::vector,363
* std::array, std::optional, or std::string/std::string_view). Returns true on success. With364
* Validate=true the input is fully checked (including no trailing junk); with Validate=false every365
* bounds/structure check is compiled out for trusted, well-formed input (malformed input is then366
* undefined behavior, and no success/trailing check is performed — it always returns true).367
*368
* std::string fields OWN their characters (safe to outlive `text`); std::string_view fields VIEW369
* `text` (which must then outlive `out`) and reject escaped strings.370
*371
* @complexity O(n) in the input length372
* @alloc only the owned std::string fields / vector growth in `out` — no DOM, no Node tree373
* @test CheatahParsersJson.TypedReadWithUnknownKeys374
* @test CheatahParsersJson.TypedReadOptionalAndReject375
* @test CheatahParsersJson.TypedReadEscapesAndKeyOrder376
*/377
template <bool Validate = true, class T>378
[[nodiscard]] bool read(std::string_view text, T& out) {379
Cursor c{text.data(), text.data() + text.size()};380
if constexpr (Validate) {381
if (!detail::read_value<true>(c, out)) {382
return false;383
}384
detail::skip_ws(c);385
return c.it == c.end; // reject trailing junk386
} else {387
// Trusted input: read with all checks compiled out, no success test, no trailing-junk check.388
detail::read_value<false>(c, out);389
return true;390
}391
}393
} // namespace cheatah::parsers::json