A forward-only JSON parser for C99 with caller-owned parsing contexts and no heap allocation by the core parser.
Forward-only parsing: consume input sequentially without constructing a document tree.
Explicit memory ownership: caller-owned parsing contexts, with allocation available through optional convenience functions.
Composable I/O: read from custom sources and decode strings into caller-selected destinations.
Small C99 core: keep file I/O and heap-backed helpers in separate optional libraries.
git clone --recurse-submodules https://github.com/johnoliverdriscoll/json99.git
cmake -S json99 -B json99/build
cmake --build json99/build
ctest --test-dir json99/build --output-on-failureSee BUILDING.md for installation and build options.
Traversal
Initialization identifies the root value. Type queries inspect the value
without consuming it. Validation happens as input is consumed. Successful reads
advance the iterator automatically. Use json_value_advance to skip and
validate a value, including its nested contents.
Containers
Iterating through an array yields elements. Iterating through an object alternates string keys and values. Entering a container creates a child iterator. Use the child to traverse the container, then leave it to resume with the parent.
Numbers
Number syntax is validated independently of numeric conversion. Numbers of any
length can be streamed as their exact original text or skipped. Integer
conversion requires an exact value within the int64_t range. Floating-point
conversion may round and accepts underflow, but rejects overflow.
Strings
Strings and object keys decode to UTF-8, including escapes and surrogate pairs. Invalid UTF-8, unpaired surrogates, and unescaped control bytes are rejected.
Ownership
Parser and iterator storage belongs to the caller. Keep the parser, reader, and borrowed input alive while parsing. Initialized adapters must stay at stable addresses. The parser does not retain decoded values for rereading.
Input boundaries
Parsing consumes one root value and leaves trailing bytes unread.
json_value_at_end reports iterator completion, not stream EOF. Applications
requiring a complete document should allow only JSON whitespace between the
root value and EOF.
Errors
Operations return JSON_OK or an error code documented in
json.h. Failures may leave input consumed and
output partially written. There is no rollback. Stop traversal and release
owned resources.
These examples use TRY to return errors to the caller. It is a local helper,
not part of the library. Use explicit error handling when cleanup or other
work must happen before returning.
#define TRY(call) \
do { \
int try_result_ = (call); \
if (try_result_ != JSON_OK) { \
return try_result_; \
} \
} while (0)Initialize a buffer reader and parser, then read a boolean:
#include <json99/json.h>
#include <sio99/sio_buffer_reader.h>
int main(void) {
const char input[] = "true";
SioBufferReader reader;
JsonParser parser;
JsonValue it;
bool value;
sio_buffer_reader_init(&reader, input, sizeof(input) - 1);
TRY(json_parser_init((SioReader*) &reader, 8, &parser, &it));
if (!json_value_is_bool(&it)) {
return 1;
}
TRY(json_value_get_bool(&it, &value));
if (!value || !json_value_at_end(&it)) {
return 1;
}
return 0;
}Use SioFileReader from <sio99/sio_file_reader.h> with the
sio99_stdio library:
SioFileReader reader;
sio_file_reader_init(&reader, file);
int result = json_parser_init((SioReader*) &reader, 16, &parser, &it);file is an open binary FILE*. The reader borrows it at its current
position. The caller is responsible for closing the file.
Read the first integer from an array and skip the rest:
int read_first_number(JsonValue* it, int64_t* out_number) {
JsonValue child;
if (!json_value_is_array(it)) {
return JSON_ERROR_INVALID;
}
TRY(json_value_enter_container(it, &child));
if (json_value_at_end(&child)) {
return JSON_ERROR_INVALID;
}
TRY(json_value_get_int64(&child, out_number));
return json_value_leave_container(it, &child);
}For [42, {"unused": [true, null]}], the result is 42.
Read an array of strings using the same caller-provided buffer for each one. Reset the writer before each read so the next string starts at the beginning:
#include <stddef.h>
#include <json99/json.h>
#include <sio99/sio_buffer_writer.h>
// Application function that processes the bytes before the next read.
void process_bytes(const void* bytes, size_t size);
int read_strings(JsonValue* it, void* buffer, size_t capacity) {
if (!json_value_is_array(it)) {
return JSON_ERROR_INVALID;
}
SioBufferWriter writer;
sio_buffer_writer_init(&writer, buffer, capacity);
JsonValue child;
TRY(json_value_enter_container(it, &child));
while (!json_value_at_end(&child)) {
sio_buffer_writer_reset(&writer);
TRY(json_value_read_string(&child, (SioWriter*) &writer));
process_bytes(buffer, sio_buffer_writer_get_size(&writer));
}
return json_value_leave_container(it, &child);
}The decoded bytes have no terminator. Each string must fit within capacity,
or reading returns JSON_ERROR_IO. Process or copy the bytes before the
next read overwrites them.
Allocate a C string, use it, and release it:
#include <stdio.h>
#include <stdlib.h>
#include <json99/json.h>
int print_string(JsonValue* it) {
char* string;
TRY(json_value_get_cstring(it, &string));
int result = JSON_OK;
if (puts(string) == EOF) {
result = JSON_ERROR_IO;
}
free(string);
return result;
}For explicit scratch storage and copy-out operations, see
SioDynamicBufferWriter in <sio99/sio_dynamic_buffer_writer.h>.
A custom writer can compare decoded bytes as they arrive. Use this helper instead of allocating a string when matching a known value or object key:
typedef struct StringMatch {
const char* next;
bool matches;
} StringMatch;
static int compare_byte(void* user, uint8_t byte) {
StringMatch* match = user;
if (match->matches) {
if (*match->next != '\0' && (uint8_t) *match->next == byte) {
match->next++;
} else {
match->matches = false;
}
}
return JSON_OK;
}
int string_equals(JsonValue* it, const char* expected, bool* equal) {
StringMatch match = {expected, true};
SioWriter writer = {&match, compare_byte};
*equal = false;
TRY(json_value_read_string(it, &writer));
*equal = match.matches && *match.next == '\0';
return JSON_OK;
}Comparison uses decoded UTF-8, so "en\u0061bled" matches "enabled".
Even a mismatch consumes and validates the whole string. Embedded zero bytes
never match the C string expected.
Use string_equals from the previous example to read enabled without
allocating keys and skip any unknown properties:
int string_equals(JsonValue* it, const char* expected, bool* equal);
int read_enabled(JsonValue* it, bool* enabled) {
JsonValue child;
if (!json_value_is_object(it)) {
return JSON_ERROR_INVALID;
}
TRY(json_value_enter_container(it, &child));
*enabled = false;
while (!json_value_at_end(&child)) {
bool matches;
TRY(string_equals(&child, "enabled", &matches));
if (matches) {
TRY(json_value_get_bool(&child, enabled));
} else {
TRY(json_value_advance(&child));
}
}
return json_value_leave_container(it, &child);
}Missing enabled defaults to false. Repeated occurrences use the last value.
Keys containing U+0000 do not match enabled and are skipped.
Read a number without losing precision through floating-point conversion:
#include <json99/json.h>
#include <sio99/sio_buffer_reader.h>
#include <sio99/sio_buffer_writer.h>
int main(void) {
const char input[] = "9007199254740993";
char output[sizeof(input) - 1];
SioBufferReader reader;
SioBufferWriter writer;
JsonParser parser;
JsonValue value;
sio_buffer_reader_init(&reader, input, sizeof(input) - 1);
sio_buffer_writer_init(&writer, output, sizeof(output));
TRY(json_parser_init((SioReader*) &reader, 0, &parser, &value));
TRY(json_value_read_number(&value, (SioWriter*) &writer));
return 0;
}The output contains the exact original digits, without a terminator.
Converting this number to double would lose precision.
Read a string without changing how its characters were escaped:
#include <json99/json.h>
#include <sio99/sio_buffer_reader.h>
#include <sio99/sio_buffer_writer.h>
int main(void) {
const char input[] = "\"caf\\u00e9\\n\"";
char output[sizeof(input) - 3];
SioBufferReader reader;
SioBufferWriter writer;
JsonParser parser;
JsonValue value;
sio_buffer_reader_init(&reader, input, sizeof(input) - 1);
sio_buffer_writer_init(&writer, output, sizeof(output));
TRY(json_parser_init((SioReader*) &reader, 0, &parser, &value));
TRY(json_value_read_raw_string(&value, (SioWriter*) &writer));
return 0;
}The output contains the literal bytes caf\u00e9\n, with no surrounding
quotes or terminator. The reader still validates the escapes and Unicode.
Using json_value_read_string instead would produce UTF-8 café followed by
a newline. When writing the preserved contents back to JSON, add surrounding
quotes without escaping the contents again.