Source
stdlib/parsers/json/scan.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::detail — the low-level JSON scanners, header-only so BOTH the dynamic DOM6
// parser (json.cpp) and the static struct reader (read.hpp) share exactly one implementation. These7
// operate on a Cursor and never allocate except where noted (decode_escapes / the caller's string).8
// SIMD whitespace/string scanning comes from simd.hpp.10
#include <charconv>11
#include <cstddef>12
#include <cstdint>13
#include <limits>14
#include <string>15
#include <string_view>16
#include <type_traits>18
#include "cursor.hpp"19
#include "simd.hpp"21
#if !defined(__cpp_lib_to_chars)22
# include <cstdlib> // strtod / strtof / strtold — see from_chars_fp23
#endif25
namespace cheatah::parsers::json::detail {27
// Advance the cursor past JSON whitespace (SIMD-accelerated; see simd.hpp).28
// @complexity O(whitespace run) @alloc none @test Json.ParseObject29
inline void skip_ws(Cursor& c) noexcept {30
c.it = simd::skip_whitespace(c.it, c.end);31
}33
// Consume the exact literal `lit` (e.g. "true") if present, else leave the cursor put.34
// @complexity O(|lit|) @alloc none @test Json.ParseObject35
inline bool match(Cursor& c, std::string_view lit) noexcept {36
if (static_cast<std::size_t>(c.end - c.it) < lit.size()) {37
return false;38
}39
if (std::string_view(c.it, lit.size()) != lit) {40
return false;41
}42
c.it += lit.size();43
return true;44
}46
// ---- string scanning + escape decoding --------------------------------------48
// Append the UTF-8 encoding of code point `cp` (1..4 bytes) to `out`.49
// @complexity O(1) @alloc amortized growth of `out` @test JsonRead.Strings50
inline void append_utf8(std::uint32_t cp, std::string& out) {51
if (cp <= 0x7F) {52
out.push_back(static_cast<char>(cp));53
} else if (cp <= 0x7FF) {54
out.push_back(static_cast<char>(0xC0 | (cp >> 6)));55
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));56
} else if (cp <= 0xFFFF) {57
out.push_back(static_cast<char>(0xE0 | (cp >> 12)));58
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));59
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));60
} else {61
out.push_back(static_cast<char>(0xF0 | (cp >> 18)));62
out.push_back(static_cast<char>(0x80 | ((cp >> 12) & 0x3F)));63
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));64
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));65
}66
}68
// Read 4 hex digits at raw[i..i+4) into cp.69
// @complexity O(1) @alloc none @test JsonRead.Strings70
inline bool hex4(std::string_view raw, std::size_t i, std::uint32_t& cp) {71
if (i + 4 > raw.size()) {72
return false;73
}74
std::uint32_t v = 0;75
for (std::size_t k = 0; k < 4; ++k) {76
const char ch = raw[i + k];77
v <<= 4;78
if (ch >= '0' && ch <= '9') {79
v |= static_cast<std::uint32_t>(ch - '0');80
} else if (ch >= 'a' && ch <= 'f') {81
v |= static_cast<std::uint32_t>(ch - 'a' + 10);82
} else if (ch >= 'A' && ch <= 'F') {83
v |= static_cast<std::uint32_t>(ch - 'A' + 10);84
} else {85
return false;86
}87
}88
cp = v;89
return true;90
}92
// Decode the raw (escaped) inner bytes of a JSON string into `out`. Ordinary bytes between escapes93
// are copied in BULK (find the next backslash, append the whole run) rather than one at a time —94
// most of a string is non-escape, so this is a few memcpy-sized appends instead of N push_backs.95
// @complexity O(|raw|) @alloc `out` growth (reserved once up front) @test JsonRead.Strings96
inline bool decode_escapes(std::string_view raw, std::string& out) {97
out.clear();98
out.reserve(raw.size());99
std::size_t i = 0;100
while (i < raw.size()) {101
const std::size_t bs = raw.find('\\', i); // next escape, or npos102
const std::size_t run_end = (bs == std::string_view::npos) ? raw.size() : bs;103
out.append(raw.data() + i, run_end - i); // bulk-copy the ordinary run104
if (bs == std::string_view::npos) {105
return true;106
}107
i = bs + 1; // step onto the escape selector108
if (i >= raw.size()) {109
return false;110
}111
switch (raw[i++]) { // consume the selector; i now points past it112
case '"': out.push_back('"'); break;113
case '\\': out.push_back('\\'); break;114
case '/': out.push_back('/'); break;115
case 'b': out.push_back('\b'); break;116
case 'f': out.push_back('\f'); break;117
case 'n': out.push_back('\n'); break;118
case 'r': out.push_back('\r'); break;119
case 't': out.push_back('\t'); break;120
case 'u': {121
std::uint32_t cp = 0;122
if (!hex4(raw, i, cp)) { // the 4 hex digits at [i, i+4)123
return false;124
}125
i += 4;126
if (cp >= 0xD800 && cp <= 0xDBFF) { // high surrogate; expect a low surrogate \uXXXX127
std::uint32_t lo = 0;128
if (i + 1 >= raw.size() || raw[i] != '\\' || raw[i + 1] != 'u' ||129
!hex4(raw, i + 2, lo) || lo < 0xDC00 || lo > 0xDFFF) {130
return false;131
}132
i += 6;133
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);134
}135
append_utf8(cp, out);136
break;137
}138
default:139
return false;140
}141
}142
return true;143
}145
// Scan a "..." string: set `raw` to its inner bytes and `esc` to whether it had escapes;146
// on entry c.it is at the opening quote, on success c.it is past the closing quote.147
// @complexity O(|string|) (SIMD 32 bytes/step) @alloc none (raw is a view) @test Json.Strings148
inline bool scan_string(Cursor& c, std::string_view& raw, bool& esc) {149
++c.it; // skip opening quote150
const char* const start = c.it;151
esc = false;152
while (c.it < c.end) {153
// SIMD-jump over ordinary content straight to the next quote or backslash (see simd.hpp).154
c.it = simd::find_quote_or_backslash(c.it, c.end);155
if (c.it == c.end) {156
break; // unterminated157
}158
if (*c.it == '"') {159
raw = std::string_view(start, static_cast<std::size_t>(c.it - start));160
++c.it; // skip closing quote161
return true;162
}163
esc = true; // a backslash: this string has escapes (decoded later)164
if (c.end - c.it < 2) {165
return false; // dangling escape at end of input (checked BEFORE advancing — a pointer166
// more than one-past-the-end is undefined behavior, even unused)167
}168
c.it += 2; // skip escape + escaped char (so \" does not end the string)169
}170
return false; // unterminated171
}173
// ---- scalars ----------------------------------------------------------------175
// Powers of ten 10^0 .. 10^22 — every one is EXACTLY representable as a double (5^22 < 2^53), which176
// is what makes the float fast path below correctly rounded.177
inline constexpr double kPow10[23] = {1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7,178
1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14, 1e15,179
1e16, 1e17, 1e18, 1e19, 1e20, 1e21, 1e22};181
/**182
* `std::from_chars` for a FLOATING-POINT type, on toolchains that have it — and an equivalent where183
* they do not.184
*185
* The integral overloads are universal, but the floating-point ones are the last piece of186
* `<charconv>` a standard library implements, and Apple's libc++ still ships them **deleted**: the187
* call compiles to `error: call to deleted function 'from_chars'`, which is how the macOS build188
* failed the first time CI ran it. `__cpp_lib_to_chars` is the feature-test macro that says both189
* halves are present, so it is what selects the path — the same shape `ndarray.hpp` already uses190
* for `__cpp_lib_execution`.191
*192
* The fallback is `strtod`, which needs a NUL-terminated string, so the numeric token is copied into193
* a small stack buffer first. `strtod` is more permissive than `from_chars` (it accepts leading194
* whitespace, a leading `+`, and hex floats), but that cannot matter here: every caller has already195
* validated the token against the JSON grammar, so none of those forms can reach this function.196
*197
* @param first the first character of the number.198
* @param last one past the last character the caller will allow it to consume.199
* @param out receives the parsed value; untouched on failure.200
* @param next receives one past the last character consumed.201
* @return false when the text is not a number the type can hold.202
* @complexity O(digits).203
* @alloc none — the token is bounded and copied to the stack; an absurdly long literal is refused204
* rather than allocated for.205
* @test JsonRead.Scalars / JsonRead.BigAndSmallDoubles206
*/207
template <class T>208
inline bool from_chars_fp(const char* first, const char* last, T& out, const char*& next) {209
#if defined(__cpp_lib_to_chars)210
const std::from_chars_result r = std::from_chars(first, last, out);211
if (r.ec != std::errc()) {212
return false;213
}214
next = r.ptr;215
return true;216
#else217
// The longest token worth honouring. A correctly-rounded double needs at most ~17 significant218
// digits; 512 leaves room for absurd-but-legal padding and still fits comfortably on the stack.219
char buf[512];220
const char* p = first;221
while (p != last && (static_cast<unsigned>(*p - '0') <= 9u || *p == '-' || *p == '+' ||222
*p == '.' || *p == 'e' || *p == 'E')) {223
++p;224
}225
const std::size_t n = static_cast<std::size_t>(p - first);226
if (n == 0 || n >= sizeof buf) {227
return false;228
}229
for (std::size_t i = 0; i < n; ++i) {230
buf[i] = first[i];231
}232
buf[n] = '\0';234
char* stop = nullptr;235
T value;236
if constexpr (std::is_same_v<T, float>) {237
value = std::strtof(buf, &stop);238
} else if constexpr (std::is_same_v<T, long double>) {239
value = std::strtold(buf, &stop);240
} else {241
value = static_cast<T>(std::strtod(buf, &stop));242
}243
if (stop == buf) {244
return false; // consumed nothing: not a number245
}246
out = value;247
next = first + static_cast<std::size_t>(stop - buf);248
return true;249
#endif250
}252
// Parse a JSON double the fast way (Clinger): accumulate the digits into an integer mantissa, then253
// scale by one power of ten. When the mantissa fits in 53 bits AND the scale is within ±22, both254
// are exact doubles, so the single multiply/divide rounds once — bit-identical to std::from_chars255
// but without its general-format machinery. Anything outside that window (20+ digits, huge256
// exponents) falls back to std::from_chars for full correctness. Typical JSON numbers — prices,257
// quantities, timestamps — live entirely in the fast window.258
// @complexity O(digits) @alloc none @test JsonRead.NumbersEdge259
inline bool parse_double_fast(Cursor& c, double& out) {260
const char* p = c.it;261
const char* const end = c.end;262
const bool negative = (p != end && *p == '-');263
if (negative) {264
++p;265
}267
// Integer digits, then optional ".fraction" — both accumulate into ONE integer mantissa;268
// each fraction digit just shifts the decimal exponent down by one.269
const char* const int_start = p;270
std::uint64_t mantissa = 0;271
while (p != end && static_cast<unsigned>(*p - '0') <= 9u) {272
mantissa = mantissa * 10u + static_cast<unsigned>(*p - '0');273
++p;274
}275
if (p == int_start) {276
return false; // JSON requires at least one integer digit277
}278
std::int64_t digit_count = p - int_start;279
int exp10 = 0;280
if (p != end && *p == '.') {281
++p;282
const char* const frac_start = p;283
while (p != end && static_cast<unsigned>(*p - '0') <= 9u) {284
mantissa = mantissa * 10u + static_cast<unsigned>(*p - '0');285
++p;286
}287
if (p == frac_start) {288
return false; // JSON requires a digit after the decimal point289
}290
exp10 -= static_cast<int>(p - frac_start);291
digit_count += p - frac_start;292
}294
// Optional exponent ("e"/"E", optional sign, digits).295
if (p != end && (*p == 'e' || *p == 'E')) {296
++p;297
bool exp_negative = false;298
if (p != end && (*p == '+' || *p == '-')) {299
exp_negative = (*p == '-');300
++p;301
}302
const char* const exp_start = p;303
int e = 0;304
while (p != end && static_cast<unsigned>(*p - '0') <= 9u) {305
if (e < 10000) { // clamp; anything this large leaves the fast window anyway306
e = e * 10 + (*p - '0');307
}308
++p;309
}310
if (p == exp_start) {311
return false; // 'e' with no digits312
}313
exp10 += exp_negative ? -e : e;314
}316
// The exactness window: <=19 digits means the mantissa accumulated without u64 overflow, the317
// 2^53 test means it is an exact double, and |exp10| <= 22 means the scale is an exact double.318
if (digit_count <= 19 && mantissa < (1ull << 53) && -22 <= exp10 && exp10 <= 22) {319
double value = static_cast<double>(mantissa);320
value = (exp10 >= 0) ? value * kPow10[exp10] : value / kPow10[-exp10];321
out = negative ? -value : value;322
c.it = p;323
return true;324
}326
// Outside the window: the general parser handles arbitrary precision/exponents correctly.327
const char* next = nullptr;328
if (!from_chars_fp(c.it, end, out, next)) {329
return false;330
}331
c.it = next;332
return true;333
}335
// Parse a JSON number into an arithmetic `out`. INTEGRAL types use a tight base-10 loop; double336
// uses the Clinger fast path above; other floating types (float, long double) defer to337
// std::from_chars. The integral loop is overflow-safe: a literal with more digits than the type can338
// ever hold exactly is re-parsed by std::from_chars, which detects out-of-range exactly; a negative339
// literal is rejected for unsigned fields rather than wrapped.340
// @complexity O(digits) @alloc none @test JsonRead.Scalars341
template <class T>342
inline bool parse_arithmetic(Cursor& c, T& out) {343
if constexpr (std::is_integral_v<T> && !std::is_same_v<T, bool>) {344
const char* p = c.it;345
const bool negative = (p != c.end && *p == '-');346
if (negative) {347
if constexpr (std::is_unsigned_v<T>) {348
return false; // a negative literal cannot fit an unsigned field349
}350
++p;351
}352
if (p == c.end || *p < '0' || *p > '9') {353
return false; // a number must have at least one digit354
}355
const char* const digit_start = p;356
std::make_unsigned_t<T> magnitude = 0;357
do {358
magnitude = magnitude * 10 + static_cast<unsigned>(*p - '0');359
++p;360
} while (p != c.end && *p >= '0' && *p <= '9');361
if (p - digit_start > std::numeric_limits<T>::digits10) {362
// More digits than T holds exactly: the accumulation above may have wrapped. Rare —363
// re-parse with std::from_chars, which detects overflow exactly (and rejects it).364
const std::from_chars_result r = std::from_chars(c.it, c.end, out);365
if (r.ec != std::errc()) {366
return false;367
}368
c.it = r.ptr;369
return true;370
}371
c.it = p;372
out = negative ? static_cast<T>(0) - static_cast<T>(magnitude) : static_cast<T>(magnitude);373
return true;374
} else if constexpr (std::is_same_v<T, double>) {375
return parse_double_fast(c, out);376
} else {377
const char* next = nullptr;378
if (!from_chars_fp(c.it, c.end, out, next)) {379
return false;380
}381
c.it = next;382
return true;383
}384
}386
// ---- skip an unknown value (iterative; depth-counted, so it cannot be stack-overflowed) ---------387
//388
// Consume exactly one complete JSON value (any shape) without storing it — used by the struct389
// reader to discard keys not present in the schema. Strings are skipped whole (so braces inside390
// them never miscount); containers are balanced with a depth counter rather than recursion, so an391
// adversarially deep unknown value costs O(depth) iterations and O(1) stack.392
// @complexity O(skipped bytes) @alloc none @test JsonRead.UnknownKeys393
inline bool skip_value(Cursor& c) {394
std::size_t depth = 0;395
do {396
skip_ws(c);397
if (c.it == c.end) {398
return false;399
}400
switch (*c.it) {401
case '{':402
case '[':403
++c.it;404
++depth;405
break;406
case '}':407
case ']':408
if (depth == 0) {409
return false;410
}411
++c.it;412
--depth;413
break;414
case '"': {415
std::string_view raw;416
bool esc = false;417
if (!scan_string(c, raw, esc)) {418
return false;419
}420
break;421
}422
case ',':423
case ':':424
if (depth == 0) {425
return false; // stray punctuation is not a value426
}427
++c.it; // structural punctuation inside a container we're skipping428
break;429
case 't':430
if (!match(c, "true")) return false;431
break;432
case 'f':433
if (!match(c, "false")) return false;434
break;435
case 'n':436
if (!match(c, "null")) return false;437
break;438
default: {439
double scratch = 0.0; // value discarded; we only need to advance past the number440
if (!parse_arithmetic(c, scratch)) {441
return false;442
}443
break;444
}445
}446
} while (depth > 0);447
return true;448
}450
} // namespace cheatah::parsers::json::detail