A small, strict JSON library for C++11. It reads JSON without silently accepting it, keeps object keys in the order they were written, tells an integer from a number, and refuses to save a configuration when a required field is missing.
There is one dependency: a vendored copy of cJSON 1.7.10, pinned byte-for-byte and used only as the parse and print backend. Everything a caller touches — the value model, the path addressing, the diagnostics — is this library.
#include <jsonlens/jsonlens.hpp>
std::string error;
jsonlens::JsonDocument document;
if (!jsonlens::JsonDocument::load_file("report.json", document, error)) {
std::fprintf(stderr, "%s\n", error.c_str());
return 1;
}
long long port = 0;
if (!document.get_integer("device.port", port, error)) {
std::fprintf(stderr, "%s\n", error.c_str()); // names the path and the kind
return 1;
}
std::string name = document.get_string_or("device.name", "unnamed");
document.set("device.enabled", jsonlens::JsonValue::make_bool(true), error);
document.save_file("report.json", error);Most C++ JSON libraries optimise for parsing large documents quickly. This one optimises for the smaller job of reading a configuration file that a human wrote, and for failing loudly when that file is wrong:
- A strict read. The text must be exactly one JSON value and it must be
well-formed UTF-8. Trailing data, duplicate object keys,
01,.5, a raw control character inside a string, an unpaired surrogate escape and a number that overflows a double are errors, not things to guess about. Duplicates are rejected because a parser that keeps the last one turns a copy-paste mistake into a value nobody can find later. - Diagnostics a person can act on. Every key and value is recorded with its line and column, and every read or write failure names the path that failed and the kind it found. A typo is a message, not a default.
- Order and type are preserved. Objects keep insertion order, so a document
written back looks like the document that was read.
1stays an integer and1.0stays a number, which matters when a field is an index and not a measurement. - No lifetime puzzles. A
JsonDocumentis a value: it is copied, moved and destroyed like one. There is no tree to free, no handle to keep alive and no borrowed pointer to misuse.
| Header | Purpose |
|---|---|
<jsonlens/value.hpp> |
JsonValue: an ordered JSON tree with null, boolean, integer, number, string, array and object kinds. |
<jsonlens/json_index.hpp> |
Token positions, token kinds and duplicate-key detection from a single scan of the text. Standalone: no JSON backend involved. |
<jsonlens/read.hpp> |
parse_json_text(): strict text to JsonValue. |
<jsonlens/write.hpp> |
to_json_text(): JsonValue to text, formatted or compact. |
<jsonlens/document.hpp> |
JsonDocument: typed getters and setters addressed by path. |
<jsonlens/text_file.hpp> |
read_text_file() and write_text_file(): a whole file at a time, with the operating system message on failure. |
<jsonlens/decimal.hpp> |
decimal_text() and signed_text(): an integer as decimal text, without iostreams, for callers that format their own messages. |
<jsonlens/version.hpp> |
Version macros and functions. |
<jsonlens/jsonlens.hpp> |
Umbrella header that includes all of the above. |
The library is header-only by default. It also ships as a small static library for projects that would rather not have one translation unit carry the backend.
add_subdirectory(third_party/jsonlens)
target_link_libraries(my_target PRIVATE jsonlens::jsonlens)That is the header-only form. To compile the backend once into a static library instead:
set(JSONLENS_COMPILED ON) # ON by default
add_subdirectory(third_party/jsonlens)
target_link_libraries(my_target PRIVATE jsonlens::compiled)jsonlens::compiled is a static library built from src/implementation.cpp
and it propagates JSONLENS_COMPILED to its consumers, so nothing else in the
program has to know which mode it is in.
The available options are:
| Option | Default | Meaning |
|---|---|---|
JSONLENS_COMPILED |
ON |
Build the jsonlens::compiled static library. |
JSONLENS_BUILD_TESTS |
OFF |
Build the self-check test suite. |
JSONLENS_BUILD_EXAMPLES |
OFF |
Build the programs in examples/. |
JSONLENS_WARNINGS_AS_ERRORS |
OFF |
Make the library's own warnings fatal. |
JSONLENS_INSTALL |
ON |
Generate install rules and the CMake package files. |
Header-only: copy include/ and, in exactly one translation unit, define
JSONLENS_IMPLEMENTATION before including any jsonlens header that uses the
backend:
#define JSONLENS_IMPLEMENTATION
#include <jsonlens/jsonlens.hpp>Compiled: compile src/implementation.cpp (which defines
JSONLENS_IMPLEMENTATION itself) and define JSONLENS_COMPILED in every other
translation unit that includes the library.
Defining both macros in one translation unit is a compile error, by design.
find_package(jsonlens CONFIG REQUIRED)
target_link_libraries(my_target PRIVATE jsonlens::jsonlens) # or jsonlens::compiledThree small programs in examples/ are built by the library and are the
quickest way to see it work:
cmake -S . -B build -DJSONLENS_BUILD_EXAMPLES=ON
cmake --build build
./build/examples/jsonlens_example_read_config examples/sample-config.json
./build/examples/jsonlens_example_diagnose examples/sample-broken.json
./build/examples/jsonlens_example_write_report /tmp/report.jsonWith MSVC the binaries are under build/examples/Release/. The first one
prints:
device sensor-1, port 115200, retries 3
channels: 115200 9600 57600
pad left: enabled
pad right: disabled
read_config asks for the fields it needs and for the ones it can default, and
prints the reason whenever it falls back. It reads an array both ways: channels
as a scalar array with get_int_array(), and pads as a table of records with
get_documents(), where each item reads its own fields by path. write_report
shows the other side: an array built in code, a second item appended by path with
set("frames[1]", ...), and an array of scalars created from nothing by the
first set("channels[0]", ...). Run it twice and diff the two files, and there is
no diff. diagnose uses json_index.hpp alone, with no backend and no
JSONLENS_IMPLEMENTATION, to list the duplicated keys of sample-broken.json
and the line and column of device.port.
The examples are documentation that the compiler checks: they are built on every push in the package jobs of CI, and they are not installed.
jsonlens::JsonValue value;
std::string error;
if (!jsonlens::parse_json_text(text, value, error)) {
// error is something like:
// unexpected trailing data at line 3 column 12
// duplicated object key(s): port
// the text holds a NUL byte at offset 12, which JSON text cannot
// not valid JSON: the JSON backend rejected the document
}The scanner in json_index.hpp decides whether the text is JSON and where every
token sits; the backend decides what each node holds. Both have to accept the
text, and the scanner is the stricter of the two.
parse_json_text() rejects:
| Input | Because |
|---|---|
{"a":1} {"b":2} |
The whole text must be one value. |
{"a":1,"a":2} |
A duplicate key is a mistake, not a precedence rule. |
01, .5, 1., +1 |
JSON numbers have a fixed grammar. |
"tab inside" |
Raw control characters must be escaped. |
| A UTF-8 byte order mark | RFC 8259 does not allow one, and accepting it would mean the text read back is not the text that was written. |
"\xc0\x80", "\xed\xa0\x80", a stray "\x80" |
The text must be valid UTF-8. Overlong forms, surrogates and code points past U+10FFFF are not. |
"\ud800" with no trailing surrogate |
Half a character is not a character, and the three bytes it would become are not UTF-8. |
"a\u0000b" |
A NUL byte inside a string is where a C string ends, so the backend would truncate the value. |
1e400 |
It overflows a double, and the backend would print it as null. |
[[[…]]] past json_nesting_limit() |
A pathological document has a bounded cost. |
The nesting limit is also what keeps the reader's recursion bounded: one read of an over-deep document fails instead of running out of stack.
A repeated key is refused by default and can be read on request, which is not the same as being tolerated:
// A tool that reports duplicates itself, and still has to describe the rest of
// the document, asks for the last value and reports the list from the scanner.
if (!jsonlens::parse_json_text(text, value, error, jsonlens::JsonDuplicateKeys::allow)) {
return 1;
}
const std::string duplicated = index.duplicated_key_list(); // "port"The value that is kept is the last one and the key stays where it first
appeared, the same rule JsonDocument::set() follows, so there is one answer to
"which one won" in the whole library.
The backend holds every number as a double, so this is where a reader can quietly lose something, and where this library stops instead:
- An integer stays an integer in the model as long as a double holds it exactly,
which is every integer below 2^53. From 2^53 up it stays a number, because
9007199254740992and9007199254740993are the same double and reporting the first for the second would be a wrong answer with the right type.get_integerandget_integer_ortherefore fail on such a value, or take the default and say so, instead of returning a nearby integer. - A number whose exponent overflows a double is an error that names the path,
not an infinity that is written back as
null. - A number written with a fraction or an exponent is a number to the model even
when its value is whole:
1.0is not1, andget_integerrefuses it whileget_numberaccepts it.
JsonIndex is a standalone scanner. It needs no JSON library, so a tool that
only has to point at a line and a column can use it on its own:
std::string error;
jsonlens::JsonIndex index;
if (!jsonlens::JsonIndex::build(text, index, error)) {
std::fprintf(stderr, "%s\n", error.c_str()); // "... at line 4 column 9"
}
const jsonlens::JsonPosition position = index.value_position("device.port");
if (jsonlens::json_position_is_known(position)) {
std::printf("device.port is at line %zu, column %zu\n", position.line, position.column);
}Every lookup and update uses the same path:
device.port
pads[2].name
/servers/0/host -> the leading slash is ignored, the rest is the same syntax
A key that contains a dot, a bracket or a backslash is escaped with a backslash;
escape_json_path_key() does that for you. An empty path means the document
itself, and an empty key is not allowed, so a path is always a walk that can be
taken.
/a/b and a.b are the same path. The leading-slash form is accepted so that a
familiar JSON-pointer-looking path works without a second parser; it is not a
second syntax.
JsonDocument has two families of getters. One fails and says why; the other
takes a fallback and records why it fell back in last_error():
long long port = 0;
if (!document.get_integer("device.port", port, error)) {
// "device.port: expected integer, found string"
// or "device.port: there is no value at this path"
}
// A field that may be absent:
long long retries = document.get_integer_or("device.retries", 3);
if (!document.last_error().empty()) {
std::fprintf(stderr, "using the default: %s\n", document.last_error().c_str());
}Available getters: get_value, get_bool, get_integer, get_number,
get_string, get_array, get_documents (an array of documents), and the
matching _or forms. last_error_ is mutable so that the _or forms can be
const; a document is therefore not safe to read from two threads at once while
either of them may fall back. Reads that fail explicitly are.
set() creates the steps a path needs and refuses everything else:
document.set("device.port", jsonlens::JsonValue::make_integer(115200), error);
document.set("device.tags[0]", jsonlens::JsonValue::make_string("uart"), error);
document.set("device.port[0]", jsonlens::JsonValue::make_null(), error);
// -> fails: "device.port: expected an array, found integer"A missing key is inserted, an existing key keeps its position, a null in the
middle of a path is replaced by the container the next step needs, and anything
else is an error that names the step that could not be walked through. A write
copies the containers it walks, so a deep path in a large document costs one
copy per level — deliberate, because the alternative is a mutable view with a
lifetime problem.
std::string text;
std::string error;
if (!jsonlens::to_json_text(document.root(), text, error, /*formatted=*/true)) {
return 1;
}The formatted layout is TAB-indented and the compact one has no whitespace; both are byte stable and covered by the golden tests, so a generated file does not show up as a diff when nothing changed. Object keys come out in insertion order.
Numbers are printed by the backend, so their spelling is part of the pinned
behaviour: 1.0 prints as 1 and -0.0 prints as -0, which is documented in
THIRD_PARTY_NOTICES.md along with the rest of the
backend pin.
Writing refuses what it cannot represent instead of truncating it: a string or a
key that holds a NUL byte fails with a message that names the path, because the
backend writes a C string and would stop at the byte. A string is otherwise
written as it is; the read path is what guarantees that everything
parse_json_text() produced is valid UTF-8, and a value built by hand is held to
the same contract.
<jsonlens/text_file.hpp> and the file helpers on JsonDocument
(load_file, save_file) read and write the whole file, not a line at a time.
A text read back is the text that was written; a file that cannot be opened or
read fails with the message from the operating system rather than silently
yielding an empty string.
The project follows Semantic Versioning. Until 1.0.0 the
minor version may carry API changes; the header names and the path syntax are
expected to stay stable. The version is available at compile time as
JSONLENS_VERSION_STRING and at run time as jsonlens::version_string().
cmake -S . -B build -DJSONLENS_BUILD_TESTS=ON -DJSONLENS_COMPILED=OFF
cmake --build build
ctest --test-dir build --output-on-failureThe suite is self-checking and has no test framework dependency. It covers the value model, the scanner, the backend's exact output, the document layer, the file helpers, the version macros, and the self-containment of every public header (each one is compiled alone).
The install rules are exercised separately by tests/consumer, a project that
only sees an installed tree:
cmake -S . -B build -DJSONLENS_COMPILED=ON
cmake --build build
cmake --install build --prefix /tmp/jsonlens
cmake -S tests/consumer -B build-consumer -DCMAKE_PREFIX_PATH=/tmp/jsonlens
cmake --build build-consumer
./build-consumer/header_only
./build-consumer/compiledCI runs on every push and pull request:
| x64 | x86 | |
|---|---|---|
| Linux (GCC, Ubuntu 24.04) | header-only + compiled | header-only + compiled, in 32-bit mode |
| Windows (MSVC, VS 2022) | header-only + compiled, Debug + Release | header-only + compiled, Debug + Release |
The vendor job checks the SHA-256 of both cJSON files against the digests
recorded in THIRD_PARTY_NOTICES.md, the format job enforces clang-format
19.1.7 on the first-party sources, and the package jobs install the library and
build tests/consumer against the prefix on Linux and on Windows.
clang-format19.1.7 is the formatter; the CI check is authoritative.- Keep public headers self-contained: each one must compile on its own.
- A behaviour change needs a test that fails without it.
- The vendored cJSON files are not to be edited. If the pin has to move, update both digests and the notes together.
MIT. See LICENSE.
The vendored cJSON 1.7.10 is also MIT and is covered in THIRD_PARTY_NOTICES.md.