cheatah
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 once
5// 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<> specializations
8// 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 its
12// typed field via std::from_chars with no intermediate double, a string is copied (std::string) or
13// viewed (std::string_view), nested structs recurse on their schema, std::vector<T> reads a
14// variable-length array, std::array<T, N> reads a FIXED N-element array inline (no allocation), and
15// std::optional<T> reads JSON null as nullopt. Unknown keys are skipped. Validate is the same
16// compile-time switch as the DOM parser: with Validate=false every bounds/structure check is
17// `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 is
20// fixed at compile time — not attacker-controlled — so natural recursion here cannot be overflowed
21// 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"
39namespace cheatah::parsers::json {
41namespace detail {
43template <class>
44struct is_vector : std::false_type {};
45template <class E, class A>
46struct is_vector<std::vector<E, A>> : std::true_type {};
48template <class>
49struct is_std_array : std::false_type {};
50template <class E, std::size_t N>
51struct is_std_array<std::array<E, N>> : std::true_type {};
53template <class>
54struct is_optional : std::false_type {};
55template <class E>
56struct is_optional<std::optional<E>> : std::true_type {};
58// Mutually-recursive workers (value -> array/object -> value). Declared first so they can call one
59// another regardless of definition order.
60//
61// read_value is FORCE-inlined: the per-instantiation call graph is acyclic (a type cannot contain
62// itself), so this is safe, and it matters — the optimizer's size heuristic otherwise outlines the
63// scalar instantiations (read_value<long>, read_value<string_view>), adding a function-call round
64// 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.Scalars
66template <bool Validate, class T>
67[[gnu::always_inline]] inline bool read_value(Cursor& c, T& out);
68template <bool Validate, class Vec>
69bool read_array(Cursor& c, Vec& out);
70template <bool Validate, class Arr>
71bool read_fixed_array(Cursor& c, Arr& out);
72template <bool Validate, class T, class... Fields>
73bool 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 (only
76// when unescaped — an escaped string has no contiguous source to view).
77// @complexity O(|string|) @alloc only for an owned std::string @test JsonRead.ViewVsOwned
78template <bool Validate, class Str>
79bool 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 instead
93 }
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 }
105template <bool Validate, class T>
106inline 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 }
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.Vectors
149template <bool Validate, class Vec>
150bool 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 array
167 }
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 }
189// Read a JSON array of EXACTLY N elements into a std::array<E, N> — fixed size, stored inline, so
190// 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.FixedArrays
192template <bool Validate, class Arr>
193bool 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 is
208 // 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;
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 is
229// 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 the
232// previous match), because real JSON nearly always lists keys in schema order. We compare the
233// input bytes directly against that field's `"name":` literal — one short memcmp replaces the
234// whole key pipeline (quote scan, key compare, colon handling). In-order compact input takes this
235// 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.OutOfOrderKeys
238template <bool Validate, class T, class... Fields>
239bool 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 decide
254 }
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 go
260 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 string
272 }
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 looking
296 }
297 read_into(f, index);
298 return true; // matched — stop the fold
299 };
300 // Search [hint, N) first, then wrap to [0, hint) — the `Is >= hint` guards on skipped
301 // 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 value
308 }
309 }
310 if (!ok) {
311 return false;
312 }
313 hint = (matched + 1 == kCount) ? 0 : matched + 1;
314 return true;
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.NestedStructs
319template <bool Validate, class T, class... Fields>
320bool 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 object
336 }
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 }
359} // namespace detail
361/**
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. With
364 * Validate=true the input is fully checked (including no trailing junk); with Validate=false every
365 * bounds/structure check is compiled out for trusted, well-formed input (malformed input is then
366 * 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 VIEW
369 * `text` (which must then outlive `out`) and reject escaped strings.
370 *
371 * @complexity O(n) in the input length
372 * @alloc only the owned std::string fields / vector growth in `out` — no DOM, no Node tree
373 * @test CheatahParsersJson.TypedReadWithUnknownKeys
374 * @test CheatahParsersJson.TypedReadOptionalAndReject
375 * @test CheatahParsersJson.TypedReadEscapesAndKeyOrder
376 */
377template <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 junk
386 } 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 }
393} // namespace cheatah::parsers::json