Skip to content

Latest commit

 

History

15 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

pjson — Praveen's JSON

pjson (short for Praveen's JSON) is an ultra-simple JSON library for C++.

  • Its intent is to make creating and accessing JSON data in C++ as simple as possible.
  • A single class, ByteDance::pjson, represents any JSON value (null, bool, number, string, array, or object) and provides an ergonomic obj["key"][i] = value style API.
  • Licensed under Apache-2.0;
  • Current source version: 2.0.0 (pjson::getVersion() / the PJSON_VERSION macro).

Table of contents


Building / Installation

Option 0 : build.sh (quickest)

From the repo root, run the bundled script. It configures CMake and builds the library, tests, and examples in both Release and Debug, collecting the artifacts into an out/ folder. Works on Linux, macOS, and Windows (Git Bash / MSYS2):

./build.sh            # full verification sweep (same as --all)
./build.sh --all      # same as above, explicitly
./build.sh --test     # just build both configs, then run the unit tests
./build.sh --bench    # run dependency-free Release benchmarks
./build.sh --bench-compare --auto  # compare with three pinned libraries
./build.sh --fuzz --auto           # bounded libFuzzer corpus smoke
./build.sh --docs --auto           # generate and validate API reference
./build.sh --package --auto        # relocatable static/shared install + pkg-config checks
./build.sh --license --auto        # validate SPDX/REUSE licensing metadata
./build.sh --clean    # remove out/ first, then build
./build.sh --release-only   # or --debug-only, to build just one config

Resulting layout:

out/
  include/pjson.h              DOM public header
  include/pjson_parser.h       parser public header
  include/pjson_schema.h       schema-validator public header
  release/lib/libpjson.a       Release library
  release/bin/pjsontest        Release test runner
  release/bin/pjsonbench       Release benchmark runner
  release/bin/examples/        Release example programs
  debug/...                    the same, built as Debug

Missing tools (cmake, clang-format, clang-tidy, Doxygen) are detected and, with your confirmation, installed via the system package manager; pass --auto to install without prompting. Run ./clean.sh to remove all generated files, including both pinned test corpora and benchmark dependencies under .test-corpora/ and .benchmark-deps/.

Option 1 : Direct source integration

Direct source integration must include every translation unit used by the selected features and the private Ryu include path for serialization. The single CMake target below is preferred for the full library. Public headers are:

Option 2 : Build with CMake directly

cmake -S . -B build
cmake --build build

Option 3 : Use from CMake

add_subdirectory(pjson) in your project and link the exported target:

add_subdirectory(pjson)
target_link_libraries(myapp PRIVATE pjson::pjson)

Or install a relocatable package:

cmake -S . -B build -DCMAKE_INSTALL_PREFIX=/desired/prefix \
  -DPJSON_BUILD_TESTS=OFF \
  -DPJSON_BUILD_EXAMPLES=OFF \
  -DPJSON_BUILD_BENCHMARKS=OFF
cmake --build build --config Release
cmake --install build --config Release

Consumers use the same target after pointing CMake at that prefix:

find_package(pjson 2.0 CONFIG REQUIRED)
target_link_libraries(myapp PRIVATE pjson::pjson)

The install also provides relocatable pkg-config metadata (pkg-config --cflags --libs pjson). Repository-local Conan 2 and vcpkg overlay recipes are available, but their presence here does not imply publication in a public package registry:

conan create . --build=missing
vcpkg install pjson --overlay-ports=packaging/vcpkg/ports

Quick start

For a standalone first program and its exact compile command, see the hello-world tutorial and its canonical source file.

#include "pjson.h"
#include "pjson_parser.h"
#include <cstdint>
#include <iostream>
using namespace ByteDance;

int main() {
    // Build a document
    pjson person;
    person["name"]   = "Ada";
    person["age"]    = int64_t(36);
    person["active"] = true;
    person["scores"][0] = int64_t(90);
    person["scores"][1] = int64_t(82);
    person["scores"][2] = int64_t(77);
    person["address"]["city"] = "London";

    const pjson::SerializeOptions pretty =
        pjson::SerializeOptions::prettyPrinted();
    std::cout << person.toString(pretty) << "\n";

    // Every DOM parse returns a pjson value; pass a pJsonParser::Error to detect failure.
    pJsonParser::Error error;
    pjson parsed = pJsonParser().parse(person.toString(), error);
    if (error.ok) {
        std::string name;
        int64_t age = 0;
        if (parsed.tryGet("name", name) && parsed.tryGet("age", age)) {
            std::cout << "name = " << name << "\n";
            std::cout << "age  = " << age << "\n";
        }
    }
}

Creating & building JSON

Assigning to operator[] builds the tree as you go — intermediate maps and arrays are created automatically.

pjson j;

// Object of key/value
j["myKey1"] = "Value1";          // const char*  -> string
j["myKey2"] = std::string("v2"); // std::string  -> string

// Nested object
j["myKey3"]["myInteger"] = int64_t(1); // signed 64-bit integer
j["myKey3"]["myFloat"]   = double(1.0); // double

// Build an array element by element
for (int64_t i = 0; i != 7; ++i)
    j["myKey4"][static_cast<int>(i)] = i;

// Direct array indexing (extends the array as needed)
j["myKey4"][7] = int64_t(7);
j["myKey4"][8] = "Eight";        // arrays may hold mixed types

// Deep nesting: map -> array -> map -> value
j["myKey4"][9]["ninth"] = double(9.0);

// Take a reference to a sub-node and keep building through it
pjson& doubles = j["myKey3"]["myFloatArray"];
doubles[0] = double(0.0);
doubles[1] = double(1.1);
doubles[3] = double(4.4);        // index 2 is auto-filled with null

One indexed access may create at most 1,000,000 children. An access that would cross that growth limit throws std::length_error before mutating the value.

Append to an array with += (it promotes the node to an array if needed):

pjson list;
list += int64_t(1);                    // [1]
list += "two";                         // [1,"two"]
list += int64_t(3);                    // [1,"two",3]
list += int64_t(4);                    // [1,"two",3,4]

The = and += operators accept strings, bool, all standard non-character integer types, and float, double, or long double, as well as vectors of the supported non-character numeric types, bool, or std::string. Concrete overloads keep ordinary expressions unambiguous without putting templates in the public header. Values are stored as signed or unsigned 64-bit integers, or as double.

The document built above serializes to:

{
  "myKey1": "Value1",
  "myKey2": "v2",
  "myKey3": {
    "myFloat": 1.0,
    "myFloatArray": [
      0.0,
      1.1,
      null,
      4.4
    ],
    "myInteger": 1
  },
  "myKey4": [
    0,
    1,
    2,
    3,
    4,
    5,
    6,
    7,
    "Eight",
    {
      "ninth": 9.0
    }
  ]
}

The skipped array position [2] is auto-filled with null by doubles[3] = 4.4. Object member order is unspecified.


Serializing (toString)

std::string compact = person.toString();       // single line, no extra spaces
pjson::SerializeOptions prettyOptions =
    pjson::SerializeOptions::prettyPrinted();
std::string pretty = person.toString(prettyOptions);

Use SerializeOptions when formatting choices must be explicit:

pjson::SerializeOptions output = pjson::SerializeOptions::prettyPrinted();
output.indentWidth = 4;
output.indentCharacter = ' '; // only space or tab; other values fall back to space
output.escapeNonAscii = true;
output.maxOutputBytes = size_t(64) * 1024 * 1024;

std::string text = person.toString(output);
person.write(std::cout, output);

Defaults are compact output, two-space indentation when pretty is enabled, raw UTF-8, and a 64 MiB output limit. Set maxOutputBytes = 0 only when explicitly requesting unlimited output. Use SerializeOptions::prettyPrinted() for two-space pretty output. Objects use private process-seeded hash storage, so insertion, traversal, keys(), and serialization order are unspecified.

For the document built in Quick start:

Compact — one possible member order is:

{"active":true,"address":{"city":"London"},"age":36,"name":"Ada","scores":[90,82,77]}

Pretty (toString(SerializeOptions::prettyPrinted())):

{
  "active": true,
  "address": {
    "city": "London"
  },
  "age": 36,
  "name": "Ada",
  "scores": [
    90,
    82,
    77
  ]
}

Strings are automatically escaped (", \, control characters, etc.). Valid UTF-8 input therefore produces valid JSON. If a programmatically stored string contains invalid UTF-8, toString() throws std::invalid_argument and write() sets failbit; escapeNonAscii does not make invalid byte sequences valid. If the configured output limit or indentation arithmetic would overflow, toString() throws std::length_error before returning output and write() sets failbit. These logical preflight failures emit no bytes; only a physical stream/I/O failure can leave a partial prefix.


Parsing

parse() — the recommended API

Every DOM-parsing overload returns a pjson by value — no smart pointer, no manual delete. The value owns its subtree and frees it on destruction. The terse overloads return a JSON null value on failure; pass a pJsonParser::Error when you need to tell failure apart from a successfully parsed literal null.

pJsonParser::Error err;
pjson p = pJsonParser().parse(R"({ "a": 1, "b": [true, null, "x"] })", err);
if (err.ok) {
    int64_t a = 0;
    if (p.tryGet("a", a))
        std::cout << a << "\n";   // 1
}   // freed automatically

A (const char*, size_t) overload handles buffers that are not NUL-terminated or that contain embedded NUL bytes:

pjson p = pJsonParser().parse(buffer, length, err);

Parse optionsparse() accepts an optional pJsonParser::Options:

pJsonParser::Options opt;
opt.maxDepth = 64;     // reject nesting deeper than this (default 512, hard cap 1024)
opt.maxNodes = 100000; // cap materialized values (default 1,000,000)
opt.maxInputBytes = 8 * 1024 * 1024; // cap input (default 64 MiB)
opt.duplicateKeys = pJsonParser::Options::RejectDuplicateKeys; // default
opt.numberPolicy = pJsonParser::Options::RejectUnrepresentableNumbers; // default
pjson p = pJsonParser(opt).parse(text);

Error reporting — pass a pJsonParser::Error to learn why/where parsing failed (no exceptions):

pJsonParser::Error err;
pjson p = pJsonParser().parse("[1, 2, ]", err);
if (!err.ok) {
    std::cerr << "parse failed at " << err.line << ':' << err.column
              << " (byte " << err.offset << "): " << err.message
              << " [code " << err.code << "]\n";
}

The parser resets the supplied pJsonParser::Error at the start of every call. Success leaves ok == true, code == None, offset 0, line 1, column 1, and an empty message; failure sets ok == false, a stable code, and records the first failure. Because a failed parse returns a null value, prefer the pJsonParser::Error overload whenever the input might legitimately be the literal null.

Strict parsing and duplicate keys

The parser always enforces RFC 8259. It rejects:

  • unescaped control characters inside strings,
  • invalid UTF-8 byte sequences,
  • unknown escapes (e.g. \q),
  • unpaired \u surrogates,
  • non-lowercase keywords (NULL, True),
  • out-of-range numbers.

Duplicate object keys are rejected by default. Set duplicateKeys to KeepFirstDuplicate or KeepLastDuplicate only when interoperability requires it. Resource budgets, duplicate-key handling, and the number policy are the only parse options; none relaxes the JSON grammar or UTF-8 validation.

Reading from a stream

std::ifstream file("data.json");
pJsonParser::Error err;
pjson doc = pJsonParser().parseStream(file, err);
if (err.ok) { /* ... */ }

parseStream() builds a normal DOM. For very large documents, derive from pJsonParser::SaxHandler and use parseSaxStream() to receive values incrementally without buffering the full file or allocating a DOM:

struct Counter : pJsonParser::SaxHandler {
    size_t numbers = 0;
    bool onInt(int64_t) override { ++numbers; return true; }
    bool onUInt(uint64_t) override { ++numbers; return true; }
    bool onDouble(double) override { ++numbers; return true; }
};

Counter counter;
pJsonParser::Error err;
std::ifstream input("huge.json", std::ios::binary);
if (!pJsonParser().parseSaxStream(input, counter, err)) {
    std::cerr << err.line << ':' << err.column << ": " << err.message << '\n';
}

Every SAX callback returns bool; return false to stop early. Callback exceptions are caught and returned as a parse failure. write(std::ostream&) also emits directly and incrementally rather than allocating a full serialized copy first.

Streaming large documents

For the complete SAX callback list, cancellation/error semantics, resource limits, and direct streaming output, see Chapter 11 — Streaming large JSON documents.


Reading values

Use tryGet() for typed reads. It returns false and leaves the destination unchanged when a node is absent or has the wrong type. The only numeric widening it permits is a stored int64_t read into a double.

Given this document:

{
  "active": true,
  "age": 36,
  "name": "Ada",
  "ratio": 0.5
}
pjson j = pJsonParser().parse(
    R"({ "name": "Ada", "age": 36, "ratio": 0.5, "active": true })");

std::string name;
int64_t age = 0;
double ratio = 0.0;
bool active = false;
if (j.tryGet("name", name) && j.tryGet("age", age) &&
    j.tryGet("ratio", ratio) && j.tryGet("active", active)) {
    // name == "Ada", age == 36, ratio == 0.5, active == true
}

const pjson* nameNode = j.find("name");
pjson::jsonType t = nameNode ? nameNode->getType() : pjson::jsonNull;

The type tags are: jsonNull, jsonString, jsonNumberInt, jsonNumberUInt, jsonNumberDouble, jsonBoolean, jsonArray, jsonObject.


Reading arrays

Given this document (shown formatted so you can see exactly what is being read):

{
  "friends": [
    {
      "name": "Bob"
    },
    {
      "name": "Cid"
    }
  ],
  "scores": [
    90,
    82,
    77
  ],
  "tags": [
    "a",
    "b",
    "c"
  ]
}
pjson j = pJsonParser().parse(
    R"({ "scores": [90, 82, 77], "tags": ["a", "b", "c"],
         "friends": [ {"name":"Bob"}, {"name":"Cid"} ] })");

Read arrays through size() and findIndex(size_t). Use find(int) only when you need negative indexes from the end. These operations do not resize or otherwise modify the array:

if (const pjson* scores = j.find("scores")) {
    std::cout << "count = " << scores->size() << "\n"; // 3
    for (size_t i = 0; i < scores->size(); ++i) {
        int64_t value = 0;
        const pjson* element = scores->findIndex(i);
        if (element && element->tryGet(value))
            std::cout << value << " "; // 90 82 77
    }
}

Array of objects — combine iteration with per-element access:

if (const pjson* friends = j.find("friends")) {
    for (size_t i = 0; i < friends->size(); ++i) {
        const pjson* entry = friends->findIndex(i);
        std::string name;
        if (entry && entry->tryGet("name", name))
            std::cout << name << " "; // Bob Cid
    }
}

Filter by element type — arrays can be heterogeneous, so check getType() when you only want some elements. Given:

{
  "mixed": [
    1,
    "two",
    3,
    true,
    4
  ]
}
// Sum only the integer elements -> 1 + 3 + 4 = 8
pjson mixed = pJsonParser().parse(R"({ "mixed": [1, "two", 3, true, 4] })");
if (const pjson* node = mixed.find("mixed")) {
    for (size_t i = 0; i < node->size(); ++i) {
        int64_t value = 0;
        const pjson* element = node->findIndex(i);
        if (element && element->tryGet(value))
            std::cout << value << " ";
    }
}

Reading objects / maps

Use keys() to obtain object keys, then find(key) to read each member without creating it. The key order is unspecified.

Given this document:

{
  "address": {
    "city": "London",
    "zip": "N1"
  },
  "name": "Ada"
}
pjson j = pJsonParser().parse(
    R"({ "name": "Ada", "address": { "city": "London", "zip": "N1" } })");

// Iterate top-level keys in unspecified order.
for (const std::string& key : j.keys()) {
    const pjson* value = j.find(key);
    if (value) {
        pjson::SerializeOptions compact;
        compact.maxOutputBytes = size_t(64) * 1024 * 1024;
        std::cout << key << " : " << value->toString(compact) << "\n";
    }
}

To look up a single key without creating it, use find() (returns a pointer or nullptr) or hasKey():

if (const pjson* addr = j.find("address")) {
    if (const pjson* city = addr->find("city")) {
        std::string value;
        if (city->tryGet(value))
            std::cout << value << "\n"; // "London"
    }
}

Safe vs. vivifying access

This is the one sharp edge worth understanding.

operator[] is a builder API: accessing a key or index that does not exist creates it, and access can change a node's type. That makes building concise, but operator[] is not a safe read. A single index access that would create more than 1,000,000 children throws std::length_error before mutation: Valid negative int indexes address existing elements from the end; a negative index before the beginning throws std::out_of_range without mutation. A size_t overload is available for ordinary non-negative builder indexes.

pjson building;
building["scores"][0] = int64_t(90);
building["scores"][1] = int64_t(82);
building["scores"][2] = int64_t(77);

building["scores"][10];    // OOPS: grows the array to 11 elements (nulls)
building["missing"];       // OOPS: creates an empty "missing" key

For non-mutating reads, use these instead. Given:

{
  "age": 36,
  "name": "Ada",
  "scores": [
    90,
    82,
    77
  ]
}
pjson j = pJsonParser().parse(
    R"({ "age": 36, "name": "Ada", "scores": [90, 82, 77] })");

// hasKey / find never create anything
if (j.hasKey("scores")) { /* ... */ }

if (const pjson* p = j.find("scores")) {
    // p is a read-only pointer, or nullptr if absent
    if (p->hasIndex(-1)) {
        const pjson* last = p->find(-1); // negative indexes count from the end
        int64_t value = 0;
        if (last && last->tryGet(value)) { /* value == 77 */ }
    }
}

int64_t age = 0;
if (j.tryGet("age", age)) { /* age == 36 */ }

For strict scalar reads, tryGet works on a node or directly by key/index. It returns false and leaves the output unchanged for a missing child or type mismatch; only integer-to-double widening is accepted:

int64_t score = -1;
if (const pjson* scores = j.find("scores")) {
    if (scores->tryGet(0, score)) { /* score == 90 */ }
}

pjson::StringView view;
if (j.tryGet("name", view)) {
    consumeBytes(view.data(), view.size()); // supports embedded NUL bytes
}

StringView avoids a string copy but borrows the node's bytes. Mutation, erasure, reset, move, swap, ancestor replacement, or destruction can invalidate it; make a std::string copy when the value must outlive that state.


Editing an existing document

Because operator[] returns a mutable reference, editing a parsed tree is the same as building one. Starting from:

{
  "status": "active",
  "user": {
    "scores": [
      10,
      20,
      30
    ]
  }
}
pjson p = pJsonParser().parse(
    R"({ "user": { "scores": [10, 20, 30] }, "status": "active" })");

// Change values in place
p["user"]["scores"][0] = int64_t(99); // change a value
p["user"]["scores"][1] = "twenty";   // change an element's type

// Replace a whole node (the "status" string becomes an array here)
p["status"][0] = int64_t(1);
p["status"][1] = int64_t(2);

std::cout << p.toString(pjson::SerializeOptions::prettyPrinted());

produces:

{
  "status": [
    1,
    2
  ],
  "user": {
    "scores": [
      99,
      "twenty",
      30
    ]
  }
}

Inspecting, comparing & modifying

Type predicates and container queries answer common questions directly:

pjson j = pJsonParser().parse(R"({ "scores": [90, 82, 77] })");

j.isObject();              // true
const pjson* scores = j.find("scores");
scores && scores->isArray();  // true
scores ? scores->size() : 0;  // 3
scores && !scores->empty();   // true
j.getType();               // pjson::jsonObject

Predicates: isNull, isString, isNumber, isInt, isUInt, isInteger, isDouble, isBool, isArray, isObject. size() returns the element count for arrays/objects (0 for scalars); empty() is size() == 0.

Read with an application default — initialize the destination, then replace it only if tryGet succeeds:

int64_t count = 0;
j.tryGet("count", count); // count remains 0 if missing or mistyped

std::string name = "anon";
j.tryGet("name", name);   // name remains "anon" on failure

Iterate object keys (unspecified order):

for (const std::string& key : j.keys()) {
    const pjson* value = j.find(key);
    if (value) {
        pjson::SerializeOptions compact;
        compact.maxOutputBytes = size_t(64) * 1024 * 1024;
        std::cout << key << " = " << value->toString(compact) << "\n";
    }
}

Modifyclear() empties a container (keeping its type), erase() removes a key or index:

j.erase("scores");         // remove a map key       -> true if present
j["list"].erase(2);        // remove array element #2 -> true if in range
j.clear();                 // empty the object (stays an object)

Compare — deep, structural equality. Numbers compare across integer/double (1 == 1.0), objects compare regardless of key order, arrays compare in order:

auto a = pJsonParser().parse(R"({"x":1,"y":[2,3]})");
auto b = pJsonParser().parse(R"({"y":[2,3],"x":1.0})");
bool same = (a == b);      // true

JSON Pointer, Patch & Merge Patch

findPointer() performs non-vivifying RFC 6901 lookup. The empty pointer selects the current value; non-empty pointers begin with /, and - is not a lookup index. Escape dynamic object-key tokens with escapePointerToken():

pjson::PointerError pointerError;
if (pjson* city = document.findPointer("/users/0/address/city", pointerError)) {
    *city = "London";
}

std::string key = "a/b~c";
const pjson* value = document.findPointer("/" + pjson::escapePointerToken(key));

applyPatch() supports the RFC 6902 add, remove, replace, move, copy, and test operations. applyMergePatch() implements RFC 7396. Both modify the receiver only after the complete operation succeeds, so failure is atomic, and the PatchError overload reports the failing operation/path. A successful RFC 6902 remove at the empty root path leaves the target as JSON null:

pjson patch = pJsonParser().parse(R"([
    {"op":"replace","path":"/status","value":"ready"},
    {"op":"add","path":"/tags/-","value":"new"}
])");

pjson::PatchError patchError;
pjson::PatchOptions patchLimits;
if (!document.applyPatch(patch, patchError, patchLimits)) {
    std::cerr << patchError.opIndex << ": " << patchError.message << '\n';
}

pjson merge = pJsonParser().parse(R"({"obsolete":null,"enabled":true})");
document.applyMergePatch(merge, patchError, patchLimits);

PatchOptions defaults to 10,000 operations, 1,000,000 cloned nodes, 64 MiB of cloned node/string/key bytes, and 1,000,000 work units. Its fields are maxOperations, maxClonedNodes, maxClonedBytes, and maxWork. Zero retains the corresponding hard ceiling rather than disabling it. Exceeding a budget returns false, reports PatchError::ResourceLimit when diagnostics are requested, and leaves the target unchanged. maxOperations applies to RFC 6902 operation entries and to members processed by Merge Patch. Moving the document root beneath itself is rejected as PatchError::MoveRootNotAllowed; removing the root remains valid and produces JSON null.


Numbers

  • Signed integers are stored and assigned as 64-bit (int64_t); read them with tryGet(int64_t&).
  • Unsigned integers above INT64_MAX are stored as 64-bit (uint64_t) in a distinct jsonNumberUInt kind, so the full uint64_t range round-trips exactly. Read them with tryGet(uint64_t&), test with isUInt(), and test either integer kind with isInteger(). An explicit uint64_t assignment keeps unsigned identity even for small values. A signed read of an unsigned value succeeds only when it fits in int64_t; an unsigned read of a signed value succeeds only when it is non-negative.
  • Non-integers are stored and assigned as double; read them with tryGet(double&). Any stored integer may widen to double; other conversions are rejected. Cross-kind comparison is exact (1 == 1u == 1.0), including above 2^53.
  • Integer tokens outside [INT64_MIN, UINT64_MAX], and floating tokens outside finite double range, are rejected by default (pJsonParser::Options::RejectUnrepresentableNumbers). Set pJsonParser::Options::AllowLossyNumbers to store the nearest finite double instead.
  • Nonzero floating tokens that round all the way to zero are also rejected by default and require AllowLossyNumbers. Other finite decimal tokens are converted by the platform's classic-locale C++ iostream implementation; on the supported libc++, libstdc++, and MSVC standard libraries this is the nearest representable double under the active floating-point rounding mode. Applications that change that mode must restore round-to-nearest before parsing when reproducibility across environments is required.
  • A stored non-finite double (NaN/±infinity) fails serialization by default (SerializeOptions::RejectNonFinite): toString() throws and write() sets failbit. Use NonFiniteToNull to emit null (the pre-2.0 behavior) or NonFiniteToString to emit "NaN"/"Infinity"/"-Infinity".
  • Double serialization is locale-independent and uses the Ryu shortest-round-trip algorithm, providing bit-exact serialize/parse recovery for every finite binary64 double. Integral-looking doubles retain a decimal marker (for example, 1.0) so reparsing preserves the double storage kind; the spelling is not promised to be the shortest possible.

The type tags are: jsonNull, jsonString, jsonNumberInt, jsonNumberUInt, jsonNumberDouble, jsonBoolean, jsonArray, jsonObject.

Thread safety

pjson makes no positive shared-object guarantee beyond the C++ default: distinct values may be used concurrently, but a single value must not be mutated concurrently with any other access to it (or its subtree). A custom Allocator must provide its own synchronization if shared across threads. The default allocator and getVersion() are initialization-safe.

pJsonSchemaValidator owns immutable schema/resource copies and may be read concurrently when callers use separate error vectors. Resolver callbacks run only during construction and are not retained.

Floating-point implementation note

Fractional and exponent-form JSON numbers are converted through a classic-locale std::istream extraction. On the supported libc++, libstdc++, and MSVC standard libraries this follows the implementation's correctly rounded decimal-to-binary conversion under the active floating-point rounding mode. pjson does not change that process/thread rounding mode. The CI matrix verifies halfway cases and binary64 extremes on GCC/libstdc++, Clang/libstdc++, AppleClang/libc++, and MSVC. Serialization uses pinned Ryu shortest-round-trip conversion, then applies pjson's documented fixed/scientific spelling policy. The bit-pattern property suite is enabled when the platform reports IEEE binary64.


Schema validation

A document can be checked against a schema that is itself a pjson object, so schemas load and round-trip through parse()/toString() like any other JSON. Validation is performed by a standalone helper class, ByteDance::pJsonSchemaValidator (declared in <pjson_schema.h>), that is a pure consumer of pjson's public API — the core pjson class carries no schema or regex machinery, while the schema implementation remains isolated in focused private translation units within the current library target. Compile a schema into a validator once, then reuse it for many instances. Default options retain pjson's deliberately limited subset dialect; Options::draft2020() selects the implemented required vocabularies of JSON Schema Draft 2020-12. validate() is noexcept and normally collects every applicable failure (a resource-budget failure stops traversal), each reported as a pJsonSchemaValidator::Error with a stable code, separate instanceLocation and schemaLocation, the triggering keyword, a human-readable message, and optional nested causes. category distinguishes InstanceValidation from SchemaCompilation.

#include "pjson_schema.h"

pjson schema = pJsonParser().parse(R"({
    "type": "object",
    "required": ["name", "age"],
    "properties": {
        "name": { "type": "string", "minLength": 1 },
        "age":  { "type": "integer", "minimum": 0 },
        "tags": { "type": "array", "items": { "type": "string" } }
    },
    "additionalProperties": false
})");

pjson data = pJsonParser().parse(R"({ "name": "Ada", "age": 36, "tags": ["x","y"] })");

// Compile the schema once, then reuse the validator.
pJsonSchemaValidator validator(schema);
if (!validator.isSchemaValid()) {
    for (const auto& e : validator.schemaErrors())
        std::cerr << "invalid schema at " << e.schemaLocation << ": " << e.message << "\n";
}

// Simple pass/fail:
if (validator.validate(data)) {
    /* conforms */
}

// Or collect all the reasons it failed:
std::vector<pJsonSchemaValidator::Error> errors;
if (!validator.validate(data, errors)) {
    for (const auto& e : errors) {
        std::cerr << (e.instanceLocation.empty() ? "(root)" : e.instanceLocation)
                  << ": " << e.message << "\n";
    }
}

Default construction implements pjson's explicitly named subset dialect. Use pJsonSchemaValidator::Options::draft2020() to opt into the official Draft 2020-12 URI, bundled standard meta-schema validation, modern $ref behavior, and per-resource $vocabulary activation. Custom meta-schema URIs are loaded only through the supplied resolver during construction; pjson never performs network I/O. Unknown optional vocabularies are annotations, while unknown required vocabularies fail compilation. Optional big-number and historic cross-draft behavior remain outside pjson's numeric and dialect contracts.

References are compiled during construction; resolver callbacks and their context are not retained, and validation performs no resolver I/O or cache mutation. A compiled validator may therefore be read concurrently when callers use separate error vectors.

Example failure output for { "age": "old" } against the schema above:

(root): missing required property "name"
/age: expected type integer, got string

Schemas can equally be built with the normal API instead of parsed from text:

pjson schema;
schema["type"] = "object";
schema["required"][0] = "name";
schema["required"][1] = "age";
schema["properties"]["name"]["type"] = "string";
schema["properties"]["age"]["type"]  = "integer";
schema["properties"]["age"]["minimum"] = int64_t(0);
bool ok = pJsonSchemaValidator(schema).validate(data);

Warning: By default, unknown or unsupported schema keywords are ignored and therefore impose no constraint. A typo can silently weaken validation. Treat the table below as an allowlist, audit schemas before use, and test both accepted and rejected instances for every intended rule. For a fail-closed boundary, use pJsonSchemaValidator::Options::strict(), which rejects unsupported standard keywords instead of ignoring them.

Supported keywords:

Applies to Keywords
any / references type (name or array of names), enum, const, $id, $anchor, $dynamicAnchor, $ref, $dynamicRef
objects properties, patternProperties, propertyNames, required, dependentRequired, dependencies, additionalProperties, unevaluatedProperties, minProperties, maxProperties
arrays single-schema or tuple-array items, prefixItems, contains, minContains, maxContains, unevaluatedItems, minItems, maxItems, uniqueItems
numbers minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf
strings minLength, maxLength, pattern (ECMAScript regex), format
combinators allOf, anyOf, oneOf, not

Notes:

  • type: "integer" matches whole numbers (including 2.0); type: "number" matches every numeric storage kind (int64_t, uint64_t, or double).
  • enum / const use pjson's deep equality, so they work for arrays and objects too.
  • A boolean schema is allowed: true accepts every value, false rejects all.
  • $ref resolves URI resources, JSON Pointer fragments, and anchors using $id bases. $dynamicRef and $dynamicAnchor follow dynamic scope. External documents require an explicit function-pointer resolver; pjson never performs network I/O. Options::modernSubset() enables modern $ref sibling semantics and makes format annotation-only unless explicitly re-enabled.
  • Known formats are date, time, date-time, ipv4, ipv6, uuid, and regex; normal options assert them, while modernSubset() follows the Draft 2020-12 annotation-only default. Unknown format names are ignored.
  • minLength and maxLength count Unicode code points, not UTF-8 bytes.
  • pattern uses ECMAScript syntax and search semantics. The default policy limits pattern and subject sizes and rejects unsafe expressions.
  • pJsonSchemaValidator::Options defaults maxRegexPatternBytes to 256, maxRegexSubjectBytes to 4096, allowUnsafeRegex to false, maxValidationDepth to 32, maxRefResolutions to 1024, maxValidationWork to 1,000,000, maxErrors to 100, maxResolvedDocuments to 32, maxResolvedBytes to 16 MiB, and validateFormats to true. Zero removes only a regex byte limit; zero for a validation, reference, work, or error budget retains its documented hard ceiling. Validation depth has an absolute hard ceiling of 32, so larger configured values are clamped to 32. trustedRegex() removes only the regex limits/safety screen; reserve it for trusted schemas and data.

The default is pjson's documented subset dialect. Draft 2020-12 mode covers its required vocabularies, but not the complete optional format-assertion vocabulary, arbitrary-precision numbers, or other drafts. See the schema tutorial and the generated API reference for details.


Error handling & allocator ownership

  • Every parse() / parseStream() overload returns a pjson by value that owns its subtree and frees it on destruction — no smart pointer, no manual delete. The terse overloads return a JSON null value on failure; pass a pJsonParser::Error to distinguish failure from a successfully parsed literal null. An exception-enabled input stream can still throw while parseStream() buffers input.
  • A supplied pJsonParser::Error is reset for each attempt. Success leaves its success state (ok, code == None, offset 0, line 1, column 1, empty message); failure records the first error with a stable code, a byte offset, one-based line and byte column, and a human-readable message.
  • The parser rejects trailing garbage, trailing/leading/doubled commas, unterminated strings/containers, malformed numbers (1., .5, 1e, +1), out-of-range numbers (1e400), and input nested deeper than pJsonParser::Options::maxDepth (itself clamped to a stack-safe hard ceiling).
  • Strings are correctly escaped on output and unescaped on input, including \uXXXX (decoded to UTF-8) and surrogate pairs.
  • Invalid UTF-8 in a programmatically stored string makes toString() throw std::invalid_argument; write() instead sets the destination stream's failbit before emitting bytes. The default SerializeOptions::maxOutputBytes is 64 MiB; exceeding it produces std::length_error from toString() or preflight failbit from write().
  • Non-throwing overloads accept SerializeError and report stable categories for invalid UTF-8, rejected non-finite numbers, output limits, allocation failure, stream failure, and internal errors. String output remains unchanged on failure; only a physical stream failure may leave a partial prefix.
  • tryGet returns false without changing its output on a missing value or type mismatch. Operations that allocate, such as copying a string or moving across allocators, can still report allocation failure through the normal C++ mechanism unless their signature is explicitly noexcept.

The default pjson and pJsonParser constructors use pjson's default allocator. Applications that need to route persistent DOM storage can derive from pjson::Allocator, bind a root during construction, or pass it to an allocator-configured parser:

class Arena : public pjson::Allocator {
public:
    void* allocate(size_t bytes, size_t alignment, AllocationKind kind) override;
    void deallocate(void* ptr, size_t bytes, size_t alignment,
                    AllocationKind kind) noexcept override;
};

Arena arena;
pjson value(arena);
pJsonParser::Error error;
pjson parsed = pJsonParser(arena).parse(text, error); // bound to arena

allocate must return non-null storage satisfying bytes and alignment or throw; deallocate receives matching metadata and must not throw. A directly constructed root such as value is caller-owned, while its wrapper objects and dynamic descendants use its bound allocator. A parsed value is likewise bound to the allocator passed to parse() and releases its storage through that allocator on destruction.

Allocator is borrowed and must outlive every bound root and descendant. It covers persistent nodes and string/array/object wrapper objects; backing allocations inside the standard containers and transient algorithm/parser scratch space still use the standard allocator.

Ordinary copy construction inherits the source allocator; pjson(source, allocator) explicitly deep-copies into another one. Copy and move assignment preserve the destination allocator. Same-allocator moves transfer storage after an ancestry-safety check, while cross-allocator moves deep-transfer and may allocate. swap() performs the same safety check before its constant-time exchange; cross-allocator and ancestor/descendant swaps are safe no-ops. Rvalue insertion snapshots an ancestor when the destination lies inside it. SAX parsing has no allocator overload because it creates no persistent DOM.


API reference (cheat sheet)

Category Members
Parse pJsonParser([allocator,] options).parse(str | ptr,size [, Error&]), parseStream(std::istream&[, Error&])pjson by value
Streaming parse pJsonParser::parseSax(...), parseSaxStream(...), pJsonParser::SaxHandler callbacks
Parse options pJsonParser::Options{ maxDepth, maxNodes, maxInputBytes, duplicateKeys, numberPolicy }, pJsonParser::Error{ ok, code, offset, line, column, message }
Serialize toString([SerializeOptions]), write(std::ostream&[, SerializeOptions]); options include maxOutputBytes, nonFinite
Type getType(), isNull/isString/isNumber/isInt/isUInt/isInteger/isDouble/isBool/isArray/isObject()
Typed read node/key/index tryGet(out&) for int64_t, uint64_t, double, bool, std::string, or StringView; untouched on failure
Inspect containers size(), empty(), keys(), hasKey(key), contains(key), hasIndex(index), find(key|index)
Traverse forEachMember(fn, ctx) in unspecified object-storage order; forEachElement(fn, ctx) in array order — non-allocating callback visitors
Checked read at(key), at(index) — throw std::out_of_range, never vivify
JSON Pointer findPointer(pointer[, PointerError]), escapePointerToken(token)
JSON Patch applyPatch(patch[, PatchError][, PatchOptions]), applyMergePatch(patch[, PatchError][, PatchOptions])
Container ops size(), empty(), clear(), erase(key), erase(index)
Compare operator==, operator!= (deep, structural)
Validate pJsonSchemaValidator v(schema[, Options]); v.validate(value[, errors]) — standalone validator (<pjson_schema.h>); subset by default, required Draft 2020-12 vocabularies via Options::draft2020()
Build operator[](key|index)vivifying
Factories null(), object(), array(); operator=(nullptr)
Insert pushBack(pjson[&&]), insertOrAssign(key, pjson[&&]), reserve(n)
Assign operator= for strings, bool, native and 64-bit integers, float, double, and matching std::vector types
Append operator+= for those same scalar and vector types; promotes the node to an array
Lifetime / allocator allocator-aware constructors, getAllocator(), canSwap(), copyFrom(), swap()
Reset reset() (→ null), resetTo(jsonType), resetIfNeeded(jsonType)

pjson copies deeply. Same-allocator moves transfer storage and null the source; cross-allocator moves deep-transfer into the destination allocator.


Testing & fuzzing

The unit test suite is the single pjsontest target under pjsontest/. It is assertion based (via a tiny header-only harness in test_harness.h), prints a PASS/FAIL line per test, and exits non-zero if any check fails. Topic files under pjsontest/src/ link into one executable; CTest discovers and registers each case individually, so the count remains derived from the source. See the testing guide for the current suite inventory and corpus setup.

Easiest — build and run through the script:

./build.sh --test

Contributors: before sending a change, run the full sweep — clean build, sanitizers, tests, formatting and static-analysis checks:

./build.sh            # or, equivalently, ./build.sh --all

The current sweep covers formatting, Release plus sanitized Debug builds, all tests, comparison benchmarks, bounded fuzz-corpus replay when supported, the Doxygen reference, relocatable static and shared install consumers, pkg-config checks, and clang-tidy. It offers to fetch both pinned JSON and JSON Schema conformance corpora; --all --auto installs or downloads missing prerequisites without prompting. Run ./build.sh --help for the exact current expansion and ./build.sh --format to apply source formatting.

Or with CMake/CTest directly:

cmake -S . -B build \
  -DPJSON_BUILD_TESTS=ON \
  -DPJSON_BUILD_EXAMPLES=OFF \
  -DPJSON_BUILD_BENCHMARKS=OFF
cmake --build build
ctest --test-dir build --output-on-failure

Or run the collected binary directly to see every case:

./out/debug/bin/pjsontest        # if built via ./build.sh
./build/pjsontest/pjsontest      # if built via plain cmake

Deterministic generated cases live in tests_fuzz.cpp; seven standalone coverage-guided targets exercise DOM parsing/round trips, stream and SAX agreement, serialization, schema validation, JSON Pointer, and the atomicity of JSON Patch and Merge Patch. With a full Clang/libFuzzer toolchain on Linux or macOS, replay the checked-in seeds with the CI-sized budget:

./build.sh --fuzz --auto

Seeds and the shared dictionary live under fuzz/; generated corpus entries and crash artifacts go under ignored out/ directories. A focused direct CMake build uses:

CXX=clang++ cmake -S . -B out/build-fuzz \
  -DPJSON_BUILD_TESTS=OFF \
  -DPJSON_BUILD_EXAMPLES=OFF \
  -DPJSON_BUILD_BENCHMARKS=OFF \
  -DPJSON_BUILD_FUZZERS=ON
cmake --build out/build-fuzz --parallel

Without PJSON_FUZZING_ENGINE, Clang must provide libFuzzer. An external engine can instead be supplied through that cache string. Repository-local OSS-Fuzz integration is provided in oss-fuzz/, without implying active upstream service enrollment.


Benchmarking

The Release benchmark suite measures parsing, compact serialization, read-only traversal, and deep copying on generated small, medium, large, wide-object, large-array, string-heavy, escape-heavy, integer-heavy, and floating-heavy JSON documents. Run pjson by itself or compare the same cases with pinned versions of nlohmann/json, RapidJSON, and simdjson:

./build.sh --bench --release-only
./build.sh --bench-compare --release-only
./build.sh --bench-compare --release-only --auto # download pinned dependencies without prompting
./build.sh --bench --release-only --bench-json out/benchmark.json

Add real documents with repeatable --bench-input arguments:

./build.sh --bench-compare --release-only \
  --bench-input /path/to/small.json \
  --bench-input /path/to/production-shaped.json

Comparison mode groups rows by workload and operation. For example, all four small / parse rows appear together, followed by all four small / serialize rows. Parse, serialize, and traverse cover every library. Copy covers pjson, nlohmann/json, and RapidJSON; simdjson has no equivalent owned mutable-DOM deep-copy operation.

Historical reference comparison

The following pre-matrix-expansion snapshot covers only the original three mixed workloads. It is retained as a directional example, not a current release result. Generate a current, provenance-bearing report with --bench-json rather than copying these values into a performance claim. The snapshot was produced on this development machine with:

./build.sh --bench-compare --release-only --auto
Machine detail Value
Computer MacBook Pro (Mac15,6)
Processor Apple M3 Pro, 12 cores (6 performance + 6 efficiency)
Memory 36 GB
Architecture ARM64
Operating system macOS 26.3.1 (25D771280a)
Compiler Apple Clang 21.0.0 (clang-2100.0.123.102)
CMake / build CMake 4.2.3, Release configuration
Compared versions nlohmann/json 3.11.3, RapidJSON 1.1.0, simdjson 3.12.2
Inputs Deterministic generated workloads; no additional corpus files
Sampling One warm-up, then six timed samples; iterations calibrated per case

Each result cell is median microseconds per operation / MiB/s. Lower microseconds are better; higher MiB/s is better. Workload sizes are the original compact JSON inputs.

Workload Operation pjson nlohmann/json RapidJSON simdjson
small (341 B) parse 4.06 us / 80.1 MiB/s 4.18 us / 77.8 MiB/s 1.68 us / 193.6 MiB/s 0.80 us / 405.4 MiB/s
small (341 B) serialize 2.74 us / 118.7 MiB/s 1.61 us / 201.5 MiB/s 1.17 us / 277.6 MiB/s 0.98 us / 331.5 MiB/s
small (341 B) traverse 0.96 us / 339.7 MiB/s 0.35 us / 922.6 MiB/s 0.27 us / 1185.6 MiB/s 0.37 us / 877.4 MiB/s
small (341 B) copy 3.13 us / 104.0 MiB/s 1.61 us / 201.4 MiB/s 1.17 us / 277.9 MiB/s N/A
medium (117,854 B) parse 1108.31 us / 101.4 MiB/s 1025.26 us / 109.6 MiB/s 292.90 us / 383.7 MiB/s 175.75 us / 639.5 MiB/s
medium (117,854 B) serialize 896.97 us / 125.3 MiB/s 464.90 us / 241.8 MiB/s 350.65 us / 320.5 MiB/s 259.48 us / 433.2 MiB/s
medium (117,854 B) traverse 271.95 us / 413.3 MiB/s 94.70 us / 1186.9 MiB/s 83.00 us / 1354.1 MiB/s 102.73 us / 1094.0 MiB/s
medium (117,854 B) copy 987.08 us / 113.9 MiB/s 406.88 us / 276.2 MiB/s 159.80 us / 703.3 MiB/s N/A
large (805,216 B) parse 14026.68 us / 54.7 MiB/s 8019.35 us / 95.8 MiB/s 2173.38 us / 353.3 MiB/s 1429.88 us / 537.0 MiB/s
large (805,216 B) serialize 21643.63 us / 35.5 MiB/s 3756.54 us / 204.4 MiB/s 2529.02 us / 303.6 MiB/s 2379.25 us / 322.8 MiB/s
large (805,216 B) traverse 2464.98 us / 311.5 MiB/s 777.77 us / 987.3 MiB/s 605.36 us / 1268.5 MiB/s 740.37 us / 1037.2 MiB/s
large (805,216 B) copy 8602.30 us / 89.3 MiB/s 3447.04 us / 222.8 MiB/s 1225.48 us / 626.6 MiB/s N/A

These numbers are a reproducible local snapshot, not a universal ranking. CPU scaling, background load, compiler versions, allocator behavior, and workload shape can materially change results. Re-run the command above on the target machine before making performance-sensitive decisions.

Measurement Interpretation Better result
best us Fastest microseconds per operation among six samples. Lower
median us Median microseconds per operation; usually the best primary comparison. Lower
avg us Mean microseconds per operation. Lower
MiB/s Original input size divided by median time. Higher
bytes Original input JSON size. Context only
iters Operations in each calibrated timed sample. Context only

MiB/s is an input-size-normalized comparison, not actual serialized, visited, or copied bytes. The final sink= value is only an anti-optimization checksum. Benchmark results have no pass/fail threshold; compare Release runs made on the same machine under similar load. CI retains machine-readable reports as advisory artifacts but does not gate on noisy shared-runner timings. See the benchmark guide for the exact timed work, dependency versions, methodology, and sample output.


Documentation & project resources

Build and validate the generated reference with Doxygen and Python 3:

cmake -S . -B out/build-docs \
  -DPJSON_BUILD_TESTS=OFF \
  -DPJSON_BUILD_EXAMPLES=OFF \
  -DPJSON_BUILD_BENCHMARKS=OFF \
  -DPJSON_BUILD_DOCS=ON
cmake --build out/build-docs --target pjson-docs-check

Open out/build-docs/docs/reference/html/index.html. Warnings and missing public API families fail validation.


Limitations

  • pjson requires C++11 and owns a mutable DOM; it is not a zero-copy parser. parseSaxStream() avoids buffering the whole document, although its handler, current tokens, nesting state, and duplicate-key tracking still use memory.
  • Object insertion and direct traversal order are not preserved. Private process-seeded hash storage accelerates lookup; keys() and serialization also use unspecified native storage order.
  • Duplicate object keys are rejected by default; pJsonParser::Options can explicitly keep the first or last value.
  • Signed integers use int64_t; unsigned integers above INT64_MAX use a distinct uint64_t kind, so the full 64-bit range round-trips exactly. Integer tokens outside [INT64_MIN, UINT64_MAX], floating overflow, and nonzero floating tokens that underflow to zero are rejected by default; opt in with pJsonParser::Options::AllowLossyNumbers to store the nearest finite double. A stored non-finite double fails serialization by default; choose NonFiniteToNull or NonFiniteToString to emit it.
  • Parsing always enforces RFC 8259, including valid UTF-8 and the standard lowercase literals and escape syntax.
  • Hostile-input limits default to 512 nesting levels, 1,000,000 materialized values, and 64 MiB of input; tune maxDepth, maxNodes, and maxInputBytes. A configured maxDepth is clamped to a stack-safe hard ceiling (1024) that cannot be raised.
  • Schema validation uses a documented subset by default and implements the required Draft 2020-12 vocabularies through Options::draft2020(). It is provided by the standalone pJsonSchemaValidator (in <pjson_schema.h>), which consumes only pjson's public API. Permissive subset mode ignores unknown keywords (Options::strict() fails closed on unsupported standard keywords), while Draft 2020-12 applies vocabulary policy per schema resource and compiles schemas against bundled or explicitly resolved meta-schemas. It supports URI resources, anchors, dynamic references, unevaluated*, conditionals, prefixItems, contains bounds, and dependentSchemas. External references and custom meta-schemas require an application resolver and never perform implicit I/O. It does not validate during SAX parsing. Tuple-form items/prefixItems validates corresponding positions. String lengths count Unicode code points. Regex matching uses the policy-limited default unless trusted mode is requested.
  • A custom Allocator routes persistent DOM nodes and wrapper objects, not transient scratch storage or the backing allocations inside standard-library containers.

About

Praveen's JSON: a simple, robust C++11 JSON library with DOM, streaming, JSON Schema, Pointer, Patch, and Merge Patch support.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

5 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages