Source
stdlib/tests/parsers_json_test.cpp
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
// In-process unit tests for the HEADER-ONLY parts of the `parsers` module: the URL parser4
// (parsers::url::Parser), the JSON schema factories (field/object), the typed struct reader5
// (read<T>() — read.hpp, which drives the shared scan.hpp scanners), the pooled construction policy6
// (PoolBuilder), the SIMD scan primitives (simd.hpp), and the JSON token classes (Boolean/Null/7
// Number/Node). The compiled DOM parser/serializer (parsers/json/json.cpp) is a direct test source8
// of this binary (see CMakeLists) and is covered separately by parsers_json_dom_test.cpp; this file9
// keeps covering the header surface directly.11
#include <cstdint>12
#include <array>13
#include <optional>14
#include <span>15
#include <string>16
#include <string_view>17
#include <tuple>18
#include <variant>19
#include <vector>21
#include <gtest/gtest.h>23
#include "json/cursor.hpp" // Cursor: the [it, end) read position the scanners advance24
#include "json/node.hpp" // Node + the Boolean/Null/Number token classes25
#include "json/pool_builder.hpp" // PoolBuilder: the pooled (viewing) construction policy26
#include "json/read.hpp" // read<T>(): parse straight into a typed struct (+ scan.hpp)27
#include "json/scan.hpp" // detail:: scanners (parse_double_fast, decode_escapes, ...)28
#include "json/schema.hpp"29
#include "json/simd.hpp" // simd:: whitespace / quote-or-backslash primitives30
#include "url/url.hpp"32
namespace json = cheatah::parsers::json;33
namespace jdet = cheatah::parsers::json::detail;34
namespace jsimd = cheatah::parsers::json::simd;35
namespace url = cheatah::parsers::url;37
namespace {39
// A struct to hang a runtime-built schema off of (mirrors how requests' Options/Response get a40
// synthesized schema<>). field()/object() are constexpr but also run at runtime.41
struct Point {42
long long x;43
std::string label;44
};46
// field() and object() are constexpr factories; requests uses them at compile time via schema<>.47
// Building a schema at RUNTIME here executes their bodies so they are covered.48
// cppcheck-suppress syntaxError // cppcheck mis-parses the member-pointer/decltype schema template49
TEST(CheatahParsers, SchemaFactoriesAtRuntime) {50
volatile bool run = true; // defeat constant-folding so the factories execute at runtime51
if (run) {52
const auto f = json::field("x", &Point::x);53
const auto sch = json::object(f, json::field("label", &Point::label));54
EXPECT_EQ(std::tuple_size_v<decltype(sch.fields)>, 2u);55
EXPECT_EQ(std::get<0>(sch.fields).name, "x");56
EXPECT_EQ(std::get<1>(sch.fields).name, "label");57
}58
}60
// The URL parser: scheme://host[:port][/path][?query], the exact grammar requests speaks.61
TEST(CheatahParsers, UrlParserComponents) {62
url::Parser p;63
url::Url u;64
// Explicit port + path + query.65
ASSERT_TRUE(p.parse("http://example.com:8080/a/b?x=1", u));66
EXPECT_EQ(u.scheme, "http");67
EXPECT_EQ(u.host, "example.com");68
EXPECT_EQ(u.port, 8080);69
EXPECT_EQ(u.target, "/a/b?x=1");70
// Default port (no colon), scheme lowercased.71
ASSERT_TRUE(p.parse("HTTP://host/path", u));72
EXPECT_EQ(u.scheme, "http");73
EXPECT_EQ(u.port, 80);74
EXPECT_EQ(u.target, "/path");75
// https default port; absent path -> "/".76
ASSERT_TRUE(p.parse("https://secure.example", u));77
EXPECT_EQ(u.port, 443);78
EXPECT_EQ(u.target, "/");79
// Bare-query form: no path, query present -> "/?...".80
ASSERT_TRUE(p.parse("http://h?q=2", u));81
EXPECT_EQ(u.target, "/?q=2");82
// Max valid port.83
ASSERT_TRUE(p.parse("http://host:65535/", u));84
EXPECT_EQ(u.port, 65535);85
}87
TEST(CheatahParsers, UrlParserRejects) {88
url::Parser p;89
url::Url u;90
EXPECT_FALSE(p.parse("no-scheme-sep", u)); // missing "://"91
EXPECT_FALSE(p.parse("://host", u)); // empty scheme92
EXPECT_FALSE(p.parse("ftp://host", u)); // unsupported scheme93
EXPECT_FALSE(p.parse("http:///path", u)); // empty authority94
EXPECT_FALSE(p.parse("http://user@host/", u)); // userinfo rejected95
EXPECT_FALSE(p.parse("http://host/p#frag", u)); // fragment rejected96
EXPECT_FALSE(p.parse("http://host:abc/", u)); // non-numeric port97
EXPECT_FALSE(p.parse("http://host:0/", u)); // port too low98
EXPECT_FALSE(p.parse("http://host:99999/", u)); // port too high99
EXPECT_FALSE(p.parse("http://:8080/", u)); // empty host with port100
EXPECT_FALSE(p.parse("http://host:/", u)); // empty port digits101
EXPECT_FALSE(p.parse("http://host:123456/", u)); // >5 port digits102
}104
// ----------------------------------------------------------------------------105
// The header-only JSON token classes (node.hpp / number.hpp / boolean.hpp / null.hpp): construct106
// each token and read it back through value(), and exercise Node's variant() (mutable + const).107
// ----------------------------------------------------------------------------108
TEST(CheatahParsersJson, TokenClassesAndNodeVariant) {109
// Number / Boolean / Null: constructor + value() accessor.110
EXPECT_DOUBLE_EQ(json::Number{3.5}.value(), 3.5);111
EXPECT_TRUE(json::Boolean{true}.value());112
EXPECT_FALSE(json::Boolean{false}.value());113
EXPECT_EQ(json::Null{}.value(), nullptr);115
// Node wraps a token in its variant; variant() has a mutable and a const overload.116
json::Node node{json::Number{42.0}};117
EXPECT_DOUBLE_EQ(std::get<json::Number>(node.variant()).value(), 42.0); // mutable variant()118
node.variant().emplace<json::Boolean>(true); // mutate via mutable ref119
const json::Node& cref = node;120
EXPECT_TRUE(std::get<json::Boolean>(cref.variant()).value()); // const variant()121
}123
// ----------------------------------------------------------------------------124
// The Array / Object / String container tokens (array.hpp / object.hpp / string.hpp): each is a125
// move-only class over its backing storage. Construct, move-construct, move-assign, read via value(),126
// and let them destruct — over BOTH the owning and viewing backings.127
// ----------------------------------------------------------------------------128
TEST(CheatahParsersJson, ContainerTokenLifecycle) {129
// String: owning (std::string) + viewing (std::string_view). Deduction guides pick the backing.130
json::String owned{std::string("owned")}; // String<std::string>131
EXPECT_EQ(owned.value(), "owned");132
json::String view{std::string_view("viewed")}; // String<std::string_view>133
EXPECT_EQ(view.value(), "viewed");135
// OwnedArray: build from a vector<Node>, move-construct and move-assign it.136
std::vector<json::Node> elems;137
elems.emplace_back(json::Number{1.0});138
elems.emplace_back(json::Boolean{false});139
json::OwnedArray arr{std::move(elems)};140
json::OwnedArray arr2{std::move(arr)}; // move-construct141
EXPECT_EQ(arr2.value().size(), 2u);142
json::OwnedArray arr3{std::vector<json::Node>{}};143
arr3 = std::move(arr2); // move-assign144
ASSERT_EQ(arr3.value().size(), 2u);145
EXPECT_DOUBLE_EQ(std::get<json::Number>(arr3.value()[0].variant()).value(), 1.0);147
// OwnedObject: build from a vector<Member>, move-construct and move-assign it.148
std::vector<json::Member> mem;149
mem.push_back(json::Member{json::Node{json::String{std::string("k")}},150
json::Node{json::Number{7.0}}});151
json::OwnedObject obj{std::move(mem)};152
json::OwnedObject obj2{std::move(obj)}; // move-construct153
EXPECT_EQ(obj2.value().size(), 1u);154
json::OwnedObject obj3{std::vector<json::Member>{}};155
obj3 = std::move(obj2); // move-assign156
ASSERT_EQ(obj3.value().size(), 1u);157
EXPECT_DOUBLE_EQ(std::get<json::Number>(obj3.value()[0].second.variant()).value(), 7.0);158
}160
// ----------------------------------------------------------------------------161
// The SIMD scan primitives (simd.hpp): whitespace-skip (fast test + long run) and quote/backslash162
// find, over inputs long enough to exercise the AVX2 32-byte block AND its scalar tail.163
// ----------------------------------------------------------------------------164
TEST(CheatahParsersJson, SimdScanPrimitives) {165
EXPECT_TRUE(jsimd::is_whitespace(' '));166
EXPECT_TRUE(jsimd::is_whitespace('\t'));167
EXPECT_FALSE(jsimd::is_whitespace('x'));169
// skip_whitespace: a >40-byte run of spaces then 'X' — past the fast path, through the block scan.170
const std::string ws = std::string(40, ' ') + "X" + std::string(5, ' ');171
const char* p = jsimd::skip_whitespace(ws.data(), ws.data() + ws.size());172
ASSERT_LT(p, ws.data() + ws.size());173
EXPECT_EQ(*p, 'X');174
// fast path: first byte already non-whitespace -> returns `it` unchanged.175
const std::string none = "abc";176
EXPECT_EQ(jsimd::skip_whitespace(none.data(), none.data() + none.size()), none.data());178
// find_quote_or_backslash: 40 ordinary bytes then a quote, then a backslash later.179
const std::string body = std::string(40, 'a') + "\"more\\x";180
const char* q = jsimd::find_quote_or_backslash(body.data(), body.data() + body.size());181
ASSERT_LT(q, body.data() + body.size());182
EXPECT_EQ(*q, '"');183
const char* bs = jsimd::find_quote_or_backslash(q + 1, body.data() + body.size());184
EXPECT_EQ(*bs, '\\');185
}187
// ----------------------------------------------------------------------------188
// The shared low-level scanners (scan.hpp detail::): number parsing (Clinger fast path + fallbacks),189
// string scanning, and escape decoding incl. \uXXXX and a surrogate pair (append_utf8 / hex4).190
// ----------------------------------------------------------------------------191
TEST(CheatahParsersJson, ScanNumbersAndEscapes) {192
auto parse_d = [](std::string_view s) {193
json::Cursor c{s.data(), s.data() + s.size()};194
double out = 0.0;195
EXPECT_TRUE(jdet::parse_double_fast(c, out)) << s;196
return out;197
};198
EXPECT_DOUBLE_EQ(parse_d("0"), 0.0);199
EXPECT_DOUBLE_EQ(parse_d("-3"), -3.0);200
EXPECT_DOUBLE_EQ(parse_d("2.5"), 2.5);201
EXPECT_DOUBLE_EQ(parse_d("-12.5e2"), -1250.0);202
EXPECT_DOUBLE_EQ(parse_d("1e-3"), 0.001);203
// Outside the exact fast window (25 digits) -> std::from_chars fallback path.204
EXPECT_DOUBLE_EQ(parse_d("1234567890123456789012345"), 1234567890123456789012345.0);206
// parse_arithmetic on an integral T: the base-10 loop, negative, and the overflow-refetch branch.207
auto parse_ll = [](std::string_view s) {208
json::Cursor c{s.data(), s.data() + s.size()};209
long long out = 0;210
EXPECT_TRUE(jdet::parse_arithmetic(c, out)) << s;211
return out;212
};213
EXPECT_EQ(parse_ll("42"), 42);214
EXPECT_EQ(parse_ll("-9223372036854775808"), -9223372036854775807LL - 1); // INT64_MIN, refetch path215
{ // unsigned rejects a negative literal216
const std::string neg = "-1";217
json::Cursor c{neg.data(), neg.data() + neg.size()};218
unsigned long long u = 0;219
EXPECT_FALSE(jdet::parse_arithmetic(c, u));220
}222
// decode_escapes drives append_utf8 for every \uXXXX form via the 1/2/3/4-byte arms:223
// A -> 'A' (1 byte), é -> U+00E9 (2 bytes), € -> U+20AC (3 bytes),224
// 😀 -> U+1F600 (surrogate pair -> 4 bytes). Plus the simple two-char225
// escapes \t \b \f \r \/ \\ \". The input is the LITERAL backslash-escape text.226
{227
// Build the escape text explicitly (each backslash is a real byte, not a C escape).228
const std::string raw =229
R"(a\tb\nA\u0041\u00e9\u20ac\ud83d\ude00\b\f\r\/\\\"z)";230
std::string decoded;231
ASSERT_TRUE(jdet::decode_escapes(raw, decoded));232
EXPECT_EQ(decoded,233
std::string("a\tb\nAA\xC3\xA9\xE2\x82\xAC\xF0\x9F\x98\x80\b\f\r/\\\"z"));234
}235
{ // scan_string over a quoted literal with an embedded escaped quote236
const std::string src = R"("he\"llo")";237
json::Cursor c{src.data(), src.data() + src.size()};238
std::string_view inner;239
bool esc = false;240
ASSERT_TRUE(jdet::scan_string(c, inner, esc));241
EXPECT_TRUE(esc);242
EXPECT_EQ(inner, R"(he\"llo)");243
}244
{ // scan_string on a plain (escape-free) string: esc stays false, bulk path.245
const std::string src = R"("plain")";246
json::Cursor c{src.data(), src.data() + src.size()};247
std::string_view inner;248
bool esc = true;249
ASSERT_TRUE(jdet::scan_string(c, inner, esc));250
EXPECT_FALSE(esc);251
EXPECT_EQ(inner, "plain");252
}253
{ // an UNTERMINATED string (no closing quote) -> scan_string returns false.254
const std::string src = "\"no end";255
json::Cursor c{src.data(), src.data() + src.size()};256
std::string_view inner;257
bool esc = false;258
EXPECT_FALSE(jdet::scan_string(c, inner, esc));259
}260
{ // a dangling escape at end of input -> scan_string returns false.261
const std::string src = "\"x\\";262
json::Cursor c{src.data(), src.data() + src.size()};263
std::string_view inner;264
bool esc = false;265
EXPECT_FALSE(jdet::scan_string(c, inner, esc));266
}267
// match(): the literal matcher used by skip_value's t/f/n arms — hit, miss, and too-short.268
{269
const std::string t = "true", n = "nullish";270
json::Cursor ct{t.data(), t.data() + t.size()};271
EXPECT_TRUE(jdet::match(ct, "true"));272
json::Cursor cn{n.data(), n.data() + n.size()};273
EXPECT_TRUE(jdet::match(cn, "null"));274
const std::string sh = "tr";275
json::Cursor cs{sh.data(), sh.data() + sh.size()};276
EXPECT_FALSE(jdet::match(cs, "true")); // too short to match277
}278
// Malformed escapes are rejected (bad \u hex, dangling backslash, lone high surrogate, and an279
// unknown escape selector \x -> the switch default).280
{281
std::string out;282
EXPECT_FALSE(jdet::decode_escapes(R"(\uZZZZ)", out));283
EXPECT_FALSE(jdet::decode_escapes("\\", out));284
EXPECT_FALSE(jdet::decode_escapes(R"(\uD83Dx)", out));285
EXPECT_FALSE(jdet::decode_escapes("\\x", out)); // unknown selector -> default: return false286
}287
{ // an input ENDING exactly at an escape's end takes the post-loop `return true` (no npos run).288
std::string out;289
EXPECT_TRUE(jdet::decode_escapes("\\t", out)); // just "\t" -> a single tab, loop exits at end290
EXPECT_EQ(out, "\t");291
}292
// skip_value: discard a complete nested value in one call — the object holds an array, a string,293
// and the three literal forms true/false/null (the t/f/n match arms) plus a number.294
{295
const std::string src = R"({"a":[1,2,{"b":true}],"c":"x","d":false,"e":null,"f":-3.5} tail)";296
json::Cursor c{src.data(), src.data() + src.size()};297
ASSERT_TRUE(jdet::skip_value(c));298
EXPECT_EQ(std::string_view(c.it, static_cast<std::size_t>(c.end - c.it)), " tail");299
}300
// skip_value rejects malformed shapes: a lone closing brace, and stray punctuation.301
{302
const std::string bad = "}";303
json::Cursor c{bad.data(), bad.data() + bad.size()};304
EXPECT_FALSE(jdet::skip_value(c));305
}306
{307
const std::string bad = ",";308
json::Cursor c{bad.data(), bad.data() + bad.size()};309
EXPECT_FALSE(jdet::skip_value(c));310
}311
{ // skip_value over just a bare literal (false) leaves the cursor at end.312
const std::string src = "false";313
json::Cursor c{src.data(), src.data() + src.size()};314
EXPECT_TRUE(jdet::skip_value(c));315
EXPECT_EQ(c.it, c.end);316
}317
}319
// ----------------------------------------------------------------------------320
// PoolBuilder (pool_builder.hpp): the pooled construction stack machine. Drive it as the DOM parser321
// would — begin/add/finish for a nested array + object — and read the resulting ArrayView/ObjectView.322
// ----------------------------------------------------------------------------323
TEST(CheatahParsersJson, PoolBuilderStackMachine) {324
json::PoolBuilder b;325
b.reset(64); // reserve pools327
// Build the array [1, "k": inner-object] -> actually: an array holding a Number then an object.328
b.begin_array();329
b.add_element(json::Node{json::Number{1.0}});330
b.begin_object(); // an object nested inside the array331
b.add_member(json::Member{json::Node{json::Number{0.0}}, json::Node{json::Boolean{true}}});332
json::Node inner_obj = b.finish_object(); // commit_members -> ObjectView333
b.add_element(std::move(inner_obj));334
json::Node arr = b.finish_array(); // commit_nodes -> ArrayView336
ASSERT_TRUE(std::holds_alternative<json::ArrayView>(arr.variant()));337
const std::span<const json::Node> elems = std::get<json::ArrayView>(arr.variant()).value();338
ASSERT_EQ(elems.size(), 2u);339
EXPECT_DOUBLE_EQ(std::get<json::Number>(elems[0].variant()).value(), 1.0);340
ASSERT_TRUE(std::holds_alternative<json::ObjectView>(elems[1].variant()));341
const auto members = std::get<json::ObjectView>(elems[1].variant()).value();342
ASSERT_EQ(members.size(), 1u);343
EXPECT_TRUE(std::get<json::Boolean>(members[0].second.variant()).value());344
}346
// ----------------------------------------------------------------------------347
// The typed struct reader read<T>() (read.hpp -> detail::skip_value + integral parse_arithmetic).348
// ----------------------------------------------------------------------------350
struct Trade {351
long long qty;352
double price;353
std::string sym;354
std::optional<long long> lot;355
std::vector<long long> tags;356
};358
// A std::array field takes read_fixed_array: exactly N elements, stored inline, never allocating.359
struct Swatch {360
std::array<long long, 3> rgb;361
std::string name;362
};364
} // namespace366
// schema<T> is a variable template; a struct opts in by specializing it with an object(field...)367
// description (the same non-intrusive shape requests synthesizes for its Response structs).368
namespace cheatah::parsers::json {369
template <>370
inline constexpr auto schema<Trade> = object(371
field("qty", &Trade::qty),372
field("price", &Trade::price),373
field("sym", &Trade::sym),374
field("lot", &Trade::lot),375
field("tags", &Trade::tags));377
template <>378
inline constexpr auto schema<Swatch> = object(379
field("rgb", &Swatch::rgb),380
field("name", &Swatch::name));381
} // namespace cheatah::parsers::json383
namespace {385
// read<T>() dispatches each field on its STATIC type: integral (parse_arithmetic base-10 loop),386
// double, string, optional (null -> nullopt), vector. An UNKNOWN key exercises detail::skip_value387
// over a nested value (object containing an array/number), which must be discarded and skipped.388
TEST(CheatahParsersJson, TypedReadWithUnknownKeys) {389
Trade t{};390
const bool ok = json::read(391
R"({"qty": -100, "extra": {"junk": [1, 2, {"deep": true}], "s": "skip\tme"},392
"price": 3.25, "sym": "AAPL", "lot": null, "tags": [10, 20, 30]})",393
t);394
ASSERT_TRUE(ok);395
EXPECT_EQ(t.qty, -100); // negative integral, base-10 loop396
EXPECT_DOUBLE_EQ(t.price, 3.25);397
EXPECT_EQ(t.sym, "AAPL");398
EXPECT_FALSE(t.lot.has_value()); // JSON null -> nullopt399
ASSERT_EQ(t.tags.size(), 3u);400
EXPECT_EQ(t.tags[1], 20);401
}403
// A present optional and a malformed body (missing '}') that read<T> must reject.404
TEST(CheatahParsersJson, TypedReadOptionalAndReject) {405
Trade t{};406
ASSERT_TRUE(json::read(R"({"qty":1,"price":2,"sym":"x","lot":7,"tags":[]})", t));407
ASSERT_TRUE(t.lot.has_value());408
EXPECT_EQ(t.lot.value_or(-1), 7);410
Trade bad{};411
EXPECT_FALSE(json::read(R"({"qty":1,"price":2,"sym":"x","lot":null,"tags":[1)", bad));412
}414
// Keys in NON-schema order (exercises the hint-wrap field search), an ESCAPED string VALUE415
// (read_string -> decode_escapes), and an ESCAPED KEY (q == 'q', decode_escapes on the key).416
TEST(CheatahParsersJson, TypedReadEscapesAndKeyOrder) {417
Trade t{};418
// "tags" first, then "sym" with a \t escape, then the escaped key "qty" (== "qty").419
const bool ok = json::read(420
R"({"tags":[1,2],"sym":"a\tb","price":1.5,"\u0071ty":9,"lot":null})", t);421
ASSERT_TRUE(ok);422
EXPECT_EQ(t.qty, 9); // matched via the escaped key423
EXPECT_EQ(t.sym, std::string("a\tb")); // value decoded through decode_escapes424
ASSERT_EQ(t.tags.size(), 2u);425
EXPECT_EQ(t.tags[0], 1);426
EXPECT_FALSE(t.lot.has_value());427
}429
// A std::array field: exactly N elements fills it, and a wrong count is a parse error rather430
// than a short read — the count check is the only thing standing between the two.431
TEST(CheatahParsersJson, TypedReadFixedArray) {432
Swatch s{};433
ASSERT_TRUE(json::read(R"({"rgb":[12,34,56],"name":"teal"})", s));434
EXPECT_EQ(s.rgb[0], 12);435
EXPECT_EQ(s.rgb[2], 56);436
EXPECT_EQ(s.name, "teal");438
Swatch few{};439
EXPECT_FALSE(json::read(R"({"rgb":[1,2],"name":"x"})", few)); // too few440
Swatch many{};441
EXPECT_FALSE(json::read(R"({"rgb":[1,2,3,4],"name":"x"})", many)); // too many442
}444
} // namespace