Skip to content

Latest commit

 

History

24 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cjson_logo

cjson 🧙‍♂️

the fastest header-only C++26 JSON parsing library

cjson is the fastest header-only C++26 JSON library: providing SIMD structural parsing, validation, minification, serialization, and writing, a lazy functional layer, and a native compile-time (constexpr) mode. Every function is constexpr qualified.


Linux Version License: MIT C++26


Warning

cjson is built with the micron core library; there is no support for the traditional C++ Standard Library or (g)libc.

Features

  • header-only
  • clean and simple API
  • fully RFC 8259 compliant
  • two-stage SIMD engine: 64-byte blocks to structural indexes / quote/escape masks / UTF-8 validation in one sweep, then a scalar FSM building a contiguous 16-byte value arena
  • full simd support for amd64 (SSSE3/AVX2+PCLMUL), aarch64 (NEON), armv7 (NEON), and a SWAR floor
  • native comptime mode: cjson::ct::parse<S>() runs during compiletime and produces a document into two flat rodata arrays; validate, minify and serialize are consteval too
  • on-demand reads
  • minification, prettification, and serialization
  • a lazy functional layer: fmap/filter/fold/take/flat_map, curried for OCaml-style | pipes
  • one-shot helpers: cjson::get<i64>(text, "/a/b")
  • any overloads for simple development: cjson::get(text, "/a/b") -> micron::any<...>, carrying objects and arrays as navigable handles, not just scalars
  • mutate in place: d["name"] = "new_name", add and remove members and elements, then write the document back out
  • a porcelain layer over micron containers: cjson::to_map(root) into any concept-satisfying micron map, and back to json again
  • io_uring backed native file i/o
  • header-only, freestanding-capable, depends only on the micron core library

Quickstart
#include <cjson/cjson.hpp>

i64 port = cjson::get_or<i64>(body, "/listen/port", i64(8080));

// on-demand
cjson::scratch sc;
auto v = cjson::iterate(body, sc);
auto root = v.cast<cjson::view>().root();
i64 exp = root["exp"].i64_or(0);
auto sub = root["sub"].str_raw();
auto add = root["add"]; // yields a micron::any type

// owning document
auto r = cjson::parse(body);
if ( r.is_second() ) { /* cjson::error_name(r.cast<cjson::error>()) */ }
const cjson::doc &d = r.cast<cjson::doc>();

// a lazy pipeline over it
namespace cj = cjson;
i64 total = d.root()["users"].items()
          | cj::filter_c(cj::field_bool("active"))
          | cj::pluck_c("age") | cj::fmap_c(cj::to_i64)
          | cj::fold_c(i64(0), cj::plus);

// build a response
cjson::builder b;
b.obj().kv("id", id).kv("name", name).end();
send(b.out().ptr, b.out().len);

// bake a config at build time
static constexpr cjson::ct::str k_cfg{ R"({"port":8080})" };
constexpr auto k_tree = cjson::ct::parse<k_cfg>();
static_assert(k_tree.root()["port"].i64_or(0) == 8080);
constexpr ready

Every function on the parse/validate/minify/write path is constexpr. Machine-specific fast paths (SIMD kernels, __builtin_memcpy puns, abcmalloc arenas) sit behind if !consteval with portable twins producing identical results. Comptime scratch is transient new[].

Benchmarks

Displayed as two separate categories The current armada of json parsers is split in two:

  • cjson, simdjson-dom and yyjson build a flat index over the caller's bytes. Strings are never materialized; cjson stores {offset, length} and unescapes on demand; objects get no key index, lookup is a linear scan at access time; teardown is one free.
  • glz::generic, rapidjson, boost.json and nlohmann build an owning, mutable tree. Every key and every string value is its own std::string, every array a vector that reallocs as it grows, every object a map that builds a lookup index; teardown is a recursive destructor walk.

Ranking all eight parsers in one list is dubious, as such for correctness the two categories are charted separately.

cjson vs simdjson-dom and yyjson -- index/tape parse of a 65KB document: throughput, cycles per op and instructions per op the same index/tape builders on a 1 MB document: throughput, cycles per op and instructions per op the same index/tape builders on a 26 MB document: throughput, cycles per op and instructions per op cjson corpus benchmark -- parse, index/tape band (GB/s) cjson corpus benchmark -- parse, owning-tree band: glz::generic, rapidjson, boost.json, nlohmann (GB/s) cjson corpus benchmark -- extract-dom, index/tape band (GB/s) cjson corpus benchmark -- serialize, index/tape band (GB/s) cjson corpus benchmark -- serialize, owning-tree band (GB/s)

Throughput, GB/s, higher is better. AMD Ryzen 7 3700U, kernel 7.1.7, GCC 16.1.1, taskset -c 0, medians of 7. Contender versions are recorded in the header of benches/results/corpus_vs.txt, which is committed alongside the charts so any figure here can be re-derived and diffed. Every contender parses the same bytes in copy mode, and every contender's object is compiled equivalently -O3 -march=native -flto -ffat-lto-objects.

Rows say what they amortize. cjson-reuse borrows a warm scratch and simdjson-dom(warm) reuses its parser across reps; plain cjson, yyjson, rapidjson, glz::generic and boost.json allocate and free per op.

glz::generic is named in full because the tier matters: it is Glaze's schema-less fallback tree, which its own documentation notes carries an allocation cost, and not its compile-time-reflected fast path. See the lazy chart below and benches/lazy_vs.cpp.

Reproduce with scripts/fetch_corpus && scripts/vsbuild benches/corpus_vs.cpp && taskset -c 0 ./bin/corpus_vs, and graph it with scripts/chart_corpus (--mode github

Lazy extraction
cjson on-demand vs glz::lazy_json, glz::get_view_json, simdjson on-demand and yyjson -- N fields resolved from one document handle, cycles per op

cyc/op, lower is better, log scale.

Each x position is N fields resolved from one document handle on sample/twitter.json (632 KB, 100 records), N from 1 to 64; an index is amortized while a walk is computed N times.

At N=1 glz::lazy_json touches 204 of 631,659 bytes (0.03%) and returns, whereas cjson's iterate reads and validates all of them.

Reproduce with scripts/vsbuild benches/lazy_vs.cpp && taskset -c 0 ./bin/lazy_vs, and graph it with scripts/chart_vs benches/results/lazy_vs.txt --metric cyc --mode github.


API

All entry points live in namespace cjson. Types are micron types.

namespace cjson {

// types
struct jnull { };                              // json null, distinct from "no value"
struct vref  { const doc *d; const value *v; };// a dom subtree, 16 bytes, navigable
struct jraw  { strv text; };                   // a span of the caller's json text

using pun    = micron::any<bool, u64, i64, f64, micron::string, vref, jraw, jnull>;
using bytes  = micron::raw_slice<const u8>;    // borrowed input view
using wbytes = micron::raw_slice<u8>;          // buffer
using strv   = micron::raw_slice<const char>;  // strings out of getters ({.ptr,.len})
using fjson  = micron::slice<u8>;              // owned byte output

enum class kind : u8 { none, raw, null, boolean, number, string, array, object };

enum class error : i32 {
  ok, bad_syntax, bad_number, bad_string, bad_escape, bad_utf8, bad_surrogate,
  depth_exceeded, trailing_garbage, empty_input, short_output, wrong_type,
  no_such_field, out_of_range, oom,
};
constexpr const char *error_name(error) noexcept;

// result<T> is micron::option<T, error>.
// success is is_first(), failure is is_second(), payload via cast<T>()
template <typename T> using result = micron::option<T, error>;

struct opts {
  bool numbers_as_raw   : 1 = false;  // store numbers as raw {ofs,len}, no conversion
  bool skip_utf8        : 1 = false;  // skip utf-8 validation of the input
  bool stop_when_done   : 1 = false;  // accept trailing bytes after the first root
  bool relaxed          : 1 = false;  // comments + trailing commas (not implemented yet)
  bool with_write_bound : 1 = false;  // accumulate O(1) writer bounds during stage 2
};

struct style {
  u8   indent     = 0;      // 0 = minified; 2/4 pretty
  bool ascii_only = false;  // not implemented yet
};

template <typename C> concept byte_source  = /* iterable container of trivially-copyable */;
template <typename C> concept text_source  = byte_source<C> || micron::is_string<C>;

bytes  as_bytes(const C &);        wbytes as_wbytes(C &);
constexpr strv as_strv(const char *);   // + strv and has_cstr overloads

// scratch and doc
struct scratch {                         // move-only
  constexpr bool ensure(usize) noexcept; // idx
  constexpr bool ensure_pool(usize) noexcept;
  constexpr bool ensure_vals(usize) noexcept;
  constexpr void release() noexcept;     // the three buffers release independently
};

class doc {                              // move-only
  constexpr val   root() const noexcept;
  constexpr usize size() const noexcept;      // value-arena slots
  constexpr usize consumed() const noexcept;  // byte offset one past the root
  constexpr bool  borrowed() const noexcept;
  constexpr bool  alive() const noexcept;
  constexpr void  release() noexcept;

  // mutating in place fns
  constexpr val   operator[](strv|const char*|usize) const noexcept;
  constexpr mut   operator[](strv|const char*|usize) noexcept;
  constexpr mut   edit() noexcept;            // mutable root, for chaining
  constexpr error mut_error() const noexcept;
  constexpr void  clear_mut_error() noexcept;
};

class mut : public val {
  const mut &operator=(bool|integral|floating|const char*|strv|is_string|nullptr) const;

  constexpr error set(i64|u64|f64|bool|strv|const char*|is_string) const noexcept;
  constexpr error set_null() const noexcept;
  constexpr error set_number(bytes) const noexcept;   // via the parser's own kernel

  // objects
  constexpr mut   insert(strv|const char*|is_string) const noexcept;  // create-or-find
  constexpr mut   insert_object(...) const noexcept;  // create-or-find as empty {}
  constexpr mut   insert_array(...) const noexcept;   // create-or-find as empty []
  constexpr error rename(strv from, strv to) const noexcept;  // in place, keeps position
  constexpr error erase(strv|const char*|is_string) const noexcept;

  // arrays
  constexpr mut   push_back() const noexcept;   // appends null, hands back the slot
  constexpr mut   push_object() const noexcept;
  constexpr mut   push_array() const noexcept;
  constexpr error erase(usize) const noexcept;

  constexpr error clear() const noexcept;       // either container -> {} or []
};

// parse / validate
// every fn below also takes: (const char*, usize), (const u8*, usize), strv, bytes, any byte container, and any micron::is_string
result<doc> parse(bytes, opts = {});                    // owning
result<doc> parse(bytes, opts, scratch &);              // owning, warm scratch
result<doc> parse_reuse(bytes, opts, scratch &);        // borrows the scratch
result<doc> parse_insitu(wbytes, opts = {});            // rewrites the input
result<doc> parse_insitu(C &, opts = {});               // mutable container/string
result<doc> parse_insitu_reuse(wbytes, opts, scratch &);

constexpr error validate(bytes, opts = {}) noexcept;
constexpr error validate(bytes, opts, scratch &) noexcept;
constexpr bool  is_valid(bytes, opts = {}) noexcept;


// general usage fns
constexpr kind  type()   const noexcept;
constexpr usize size()   const noexcept;  // val: elements / PAIRS / bytes by kind
constexpr usize count()  const noexcept;  // cur: same, by walking
constexpr explicit operator bool() const noexcept;
constexpr bool  is_null() const noexcept;

// lossy
constexpr i64  i64_or (i64  = 0)     const noexcept;
constexpr u64  u64_or (u64  = 0)     const noexcept;
constexpr f64  f64_or (f64  = 0)     const noexcept;
constexpr bool bool_or(bool = false) const noexcept;
constexpr strv str_or (strv = {})    const noexcept;   // val: escapes decoded
constexpr strv str_raw()             const noexcept;   // cur: escapes retained
constexpr max_t str(wbytes out)      const noexcept;   // cur: decode into your buffer

constexpr result<i64>  try_i64()  const noexcept;
constexpr result<u64>  try_u64()  const noexcept;
constexpr result<f64>  try_f64()  const noexcept;
constexpr result<bool> try_bool() const noexcept;
constexpr result<strv> try_str()  const noexcept;

// navigation
constexpr val/cur operator[](strv key) const noexcept;  // first match wins on dupes
constexpr val/cur at(usize i)          const noexcept;  // unambiguous array index
constexpr val/cur at_pointer(strv ptr) const noexcept;  // rfc 6901; "" names the root

// iteration
constexpr arr_range     items()   const noexcept;  // yields val
constexpr obj_range     members() const noexcept;  // yields member { strv key; val v; }
constexpr cur_arr_range items()   const noexcept;  // yields cur
constexpr cur_obj_range members() const noexcept;  // yields cur_member { strv key; cur v; }

// on-demand
result<view> iterate(bytes, scratch &);         // opts defaulted
result<view> iterate(bytes, opts, scratch &);
class view { constexpr cur root() const noexcept; constexpr bool alive() const noexcept; };

// functional adaptors/lazy pipelines

// adaptors            eager, function-first        curried, range-last
fmap(fn, r)            filter(p, r)                 fmap_c(fn)      filter_c(p)
reject(p, r)           take(n, r)   drop(n, r)      reject_c(p)     take_c(n)  drop_c(n)
take_while(p, r)       drop_while(p, r)             take_while_c(p) drop_while_c(p)
enumerate(r)           flat_map(fn, r)              enumerate_c()   flat_map_c(fn)
keys(r)  values(r)     pluck(key, r)                keys_c() values_c() pluck_c(key)

// terminals
fold(r, init, fn)      count(r)        count_if(p, r)
any_of(p, r)           all_of(p, r)    none_of(p, r)
find_first(p, r)       -> result<T>   
max_by(proj, r)        min_by(proj, r) -> result<T>
for_each(fn, r)
// ... each with a _c (curried) form: fold_c(init, fn), any_of_c(p), find_first_c(p)

// terminal
collect_into<C>(r)     collect_into_c<C>()

// combinators and projections
plus  minus  times  max_of  min_of
to_i64  to_u64  to_f64  to_bool  to_str
is_truthy   is_kind(k)   has(key)   key_is(key)
field(key)          
field_i64(key)  field_u64(key)  field_f64(key)  field_bool(key)   // ordered/typed


// one shot helpers
template <class T> result<T> get(jtext, jptr, opts = {});        // i64/u64/f64/bool/string
template <class T> result<T> get(jtext, jptr, scratch &);
template <class T> T         get_or(jtext, jptr, T dflt, opts = {});
result<strv>  get_str_raw(jtext, jptr, scratch &);   // borrows

bool          exists(jtext, jptr, opts = {});
kind          kind_at(jtext, jptr, opts = {});
result<usize> count_at(jtext, jptr, opts = {});
error         each(jtext, jptr, Fn);                 // Fn takes cur or cur_member

bool                   valid(jtext, opts = {}) noexcept;
result<micron::string> compact(jtext, opts = {});
result<micron::string> pretty(jtext, u8 indent = 2, opts = {});
result<micron::string> reformat(jtext, style, opts = {});

// writing
constexpr usize write_bound(const doc &, style = {}) noexcept;  // O(1) with .with_write_bound
constexpr max_t write_into(const doc &, wbytes, style = {}) noexcept;
fjson           write(const doc &, style = {});                 // owning
micron::string  write_str(const doc &, style = {});

constexpr usize minify_bound(usize n) noexcept;                 // == n
constexpr max_t minify(bytes in, wbytes out, opts = {}) noexcept;
result<fjson>          minify(bytes, opts = {});
result<micron::string> minify_str(bytes, opts = {});

// subtree writers
constexpr usize        write_bound(val, style = {}) noexcept;
constexpr max_t        write_into (val, wbytes, style = {}) noexcept;
micron::string         write_str  (val, style = {});
fjson                  write      (val, style = {});

// json <-> micron containers (runtime only)
using object_map = micron::hswiss<micron::string, pun>;
using array_vec  = micron::vector<pun>;

result<object_map> to_map(val);        result<object_map> to_map(cur);
result<array_vec>  to_vector(val);     result<array_vec>  to_vector(cur);

template <pun_map M> error to_map_into(val|cur, M &);
template <pun_seq V> error to_vector_into(val|cur, V &);

constexpr usize map_slots(val) noexcept;

template <pun_map M> result<micron::string> to_json(const M &);
template <pun_seq V> result<micron::string> to_json_seq(const V &);
template <pun_map M> error write_map(builder &, const M &);
template <pun_seq V> error write_seq(builder &, const V &);
void write_pun(builder &, const pun &);    // one value, any kind, into a builder

struct key_view { key_view(strv); strv view() const noexcept; /* ... */ };

class builder {
  builder();  explicit builder(micron::string &&reuse);  // recycle the buffer
  error err() const noexcept;
  builder &obj();  builder &arr();  builder &end();
  builder &key(strv);                              // + const char*, is_string
  builder &value(strv);                            // + const char*, is_string
  builder &value(i64/u64/i32/u32/f64/bool);
  builder &null();
  builder &raw(strv json);                         // preserialized, trusted verbatim
  template <class V> builder &kv(key, V v);
  strv           out() noexcept;                   // empty unless balanced and clean
  micron::string take() noexcept;                  // move the buffer out, reset
};

} // namespace cjson

namespace cjson::ct {   // comptime consteval

template <usize N> struct str;             // NTTP carrier for a json literal
template <str S, opts O = {}> consteval bool  validate();
template <str S, opts O = {}> consteval auto  minify();        // -> bytes<N>
template <str S, opts O = {}> consteval auto  parse();         // -> tree<NV, NS>
template <auto &Tree, style St = {}> consteval auto write();   // -> bytes<N>

examples/ has nine programs covering each layer:

01_quickstart.cpp parse → read → build → write, end to end
02_dom.cpp doc/val, getters, iteration, pointers, lifetimes
03_ondemand.cpp iterate, scratch reuse, and the borrowing rules
04_functional.cpp the whole fp layer, laziness made visible
05_oneshot.cpp get/exists/each/pretty and friends
06_build_write.cpp builder, write, minify, the sticky error
07_comptime.cpp ct:: — almost entirely static_assert
08_strings.cpp every text flavour through every entry point
09_porcelain.cpp pun, to_map, and the micron container fns
Configuration
CJSON_DEPTH_LIMIT     // nesting cap; default 1024, 0 folds the counter away entirely
Building & integration

Header-only. micron core headers must be reachable as <micron/...>; requires C++26 (GCC 16+).

An easy to use script has been provided to fetch micron, run cd include/ && bash fetch_micron.sh to clone micron locally, then run any of the prebaked build_.*.sh scripts.

./build_benches.sh
./build_examples.sh

# if you want to use duck (build tool, you need to compile it via tools/src/main.cc in micron)

duck batch parallel build.duck        # tools et al
duck batch parallel tests.duck        # snowball suites (exit 1 == pass per binary)
duck batch parallel examples.duck     # eight examples
scripts/ctbuild                       # comptime stress tier (raised constexpr limits)

# to run benchmarks
duck batch paralle benches.duck          # benches
or ./build_benches.sh 
scripts/fetch_corpus                     # wide-net corpus into sample/web/
scripts/vsbuild benches/corpus_vs.cpp    # head-to-head against six libraries
taskset -c 0 ./bin/corpus_vs > benches/results/corpus_vs.txt
scripts/chart_corpus benches/results/corpus_vs.txt --headline
scripts/chart_corpus benches/results/corpus_vs.txt --all-metrics
scripts/chart_corpus benches/results/corpus_vs.txt --headline --mode github
scripts/chart_vs benches/results/parse_vs.txt            # same style, parse_vs/write_vs
scripts/chart_vs benches/results/write_vs.txt --mode github
scripts/snapshot <bench>

A full corpus_vs sweep is long, nlohmann and boost.json are two orders of magnitude slower than cjson on the object-heavy corpora. Pass only= to reduce fields benchmarked.

taskset -c 0 ./bin/corpus_vs parse only=twitter,canada,numbers
Limitations
  • No runtime CPU dispatch by design: a binary built with AVX2 requires AVX2.
  • The lazy fp layer is left-fold only
  • A warm scratch holds memory proportional to the largest document it has seen until release().
  • The porcelain fns (pun, to_map, to_vector) are runtime-only.
  • Mutation invalidates handles on structural edits.
  • Erasing a member does not reclaim its pool bytes.
  • On the on-demand path, object keys marshalled by to_map are raw\u escapes are not decoded, matching cur::str_raw().
  • Depends on the micron core library as its sole dependency.

License

MIT License — see LICENSE.

About

modern, functional (and the fastest) c++26 json parsing library

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages