95 Writing it three times — a tiny JSON
What to know first
Looking back
Part XII has walked through five contracts one at a time — errors are values, a view is borrowed, the allocator is a parameter, state is not copied, refuse rather than truncate. So what changes in code when all five apply at once, inside one program?
A. What changes is not the syntax but where things are written down. Written in plain C, container sizes, failure handling and the source of memory are scattered through the code as convention; written with proven, the same things come out in types, return values and parameters. Rather than describe the difference, this chapter writes the same program several times and shows it.
The need for this chapter, and its context
By the end of this chapter
The questions this chapter answers
- Does dropping recursion not make the code longer and harder to read?
- The proven edition is the longer one. Where is the gain?
- Is the plain edition useless, then?
95.1 What we are building
Build all of it and this becomes a parser textbook rather than this book. So the scope is narrowed like this.
| In | Out |
|---|---|
One flat object — { "key": value, ... } | Nesting (in the first two editions; the third solves it) |
| Four kinds of value — string, integer, boolean, null | Reals, exponent notation |
| Reading and writing back (a round trip) | \u escapes, comments |
| Where a failure happened | Recovery, partial parses |
Table 96.1
Even narrowed, everything this part is about fits inside: the size of the container, pointing into someone else’s memory, integer overflow, how failure is announced, and who provides the memory. Nesting is taken up in the last section by a third edition — without recursion, on an explicit stack.
95.2 The plain C edition
examples-en/ch95/json_plain.c
/* A tiny JSON — the plain C edition. Reads one flat object and writes it back.
Values are narrowed to four kinds: string, integer, boolean, null. */
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_PAIRS 16
#define MAX_KEY 32
#define MAX_STR 128
typedef enum { J_STR, J_NUM, J_BOOL, J_NULL } jkind;
typedef struct {
char key[MAX_KEY];
jkind kind;
char str[MAX_STR]; /* only when J_STR */
long num; /* only when J_NUM */
bool boolean; /* only when J_BOOL */
} jpair;
typedef struct {
jpair pair[MAX_PAIRS];
int count;
} jdoc;
static const char *skip_ws(const char *p)
{
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
return p;
}
/* Copy one quoted string into dst. Returns the next position, or NULL on failure. */
static const char *take_string(const char *p, char *dst, size_t cap)
{
if (*p != '"') return NULL;
p++;
size_t i = 0;
while (*p && *p != '"') {
if (*p == '\\') { /* escapes: the bare minimum */
p++;
if (*p == '\0') return NULL;
}
if (i + 1 < cap) dst[i++] = *p; /* overflow is dropped silently — a trap */
p++;
}
if (*p != '"') return NULL;
dst[i] = '\0';
return p + 1;
}
/* Returns 0 on success, -1 on failure with the reason written into err. */
static int json_parse(const char *text, jdoc *out, char *err, size_t errcap)
{
out->count = 0;
const char *p = skip_ws(text);
if (*p != '{') { snprintf(err, errcap, "object expected"); return -1; }
p = skip_ws(p + 1);
if (*p == '}') return 0;
for (;;) {
if (out->count >= MAX_PAIRS) {
snprintf(err, errcap, "too many pairs (max %d)", MAX_PAIRS);
return -1;
}
jpair *e = &out->pair[out->count];
p = take_string(p, e->key, sizeof e->key);
if (!p) { snprintf(err, errcap, "key expected"); return -1; }
p = skip_ws(p);
if (*p != ':') { snprintf(err, errcap, "':' expected"); return -1; }
p = skip_ws(p + 1);
if (*p == '"') {
e->kind = J_STR;
p = take_string(p, e->str, sizeof e->str);
if (!p) { snprintf(err, errcap, "string expected"); return -1; }
} else if (strncmp(p, "true", 4) == 0) {
e->kind = J_BOOL; e->boolean = true; p += 4;
} else if (strncmp(p, "false", 5) == 0) {
e->kind = J_BOOL; e->boolean = false; p += 5;
} else if (strncmp(p, "null", 4) == 0) {
e->kind = J_NULL; p += 4;
} else {
char *end;
long v = strtol(p, &end, 10); /* overflow only shows in errno — easy to forget */
if (end == p) { snprintf(err, errcap, "value expected"); return -1; }
e->kind = J_NUM; e->num = v; p = end;
}
out->count++;
p = skip_ws(p);
if (*p == ',') { p = skip_ws(p + 1); continue; }
if (*p == '}') return 0;
snprintf(err, errcap, "',' or '}' expected");
return -1;
}
}
/* Write it back out. If it does not fit, it goes out cut — snprintf's contract. */
static int json_write(const jdoc *doc, char *buf, size_t cap)
{
size_t used = 0;
int n = snprintf(buf + used, cap - used, "{");
if (n < 0) return -1;
used += (size_t)n;
for (int i = 0; i < doc->count; i++) {
const jpair *e = &doc->pair[i];
n = snprintf(buf + used, cap - used, "%s\"%s\":", i ? "," : "", e->key);
if (n < 0 || (size_t)n >= cap - used) return -1;
used += (size_t)n;
switch (e->kind) {
case J_STR: n = snprintf(buf + used, cap - used, "\"%s\"", e->str); break;
case J_NUM: n = snprintf(buf + used, cap - used, "%ld", e->num); break;
case J_BOOL: n = snprintf(buf + used, cap - used, "%s",
e->boolean ? "true" : "false"); break;
case J_NULL: n = snprintf(buf + used, cap - used, "null"); break;
}
if (n < 0 || (size_t)n >= cap - used) return -1;
used += (size_t)n;
}
n = snprintf(buf + used, cap - used, "}");
if (n < 0 || (size_t)n >= cap - used) return -1;
return 0;
}
int main(void)
{
const char *text =
"{ \"name\": \"proven\", \"year\": 2026, \"draft\": true, \"note\": null }";
jdoc doc;
char err[64];
if (json_parse(text, &doc, err, sizeof err) != 0) {
printf("parse failed: %s\n", err);
return 1;
}
printf("pairs: %d\n", doc.count);
for (int i = 0; i < doc.count; i++) {
const jpair *e = &doc.pair[i];
printf(" %-6s = ", e->key);
switch (e->kind) {
case J_STR: printf("\"%s\"\n", e->str); break;
case J_NUM: printf("%ld\n", e->num); break;
case J_BOOL: printf("%s\n", e->boolean ? "true" : "false"); break;
case J_NULL: printf("null\n"); break;
}
}
char out[256];
if (json_write(&doc, out, sizeof out) == 0) printf("\nwritten: %s\n", out);
else printf("\nwrite failed (buffer too small)\n");
/* Show the limit — a value longer than the container is cut silently */
jdoc big;
const char *long_text =
"{ \"k\": \"0123456789012345678901234567890123456789"
"0123456789012345678901234567890123456789"
"0123456789012345678901234567890123456789"
"0123456789012345678901234567890123456789\" }";
if (json_parse(long_text, &big, err, sizeof err) == 0)
printf("long value: kept %zu of %zu characters (silently cut)\n",
strlen(big.pair[0].str), strlen(long_text) - 12);
return 0;
}
Output
pairs: 4
name = "proven"
year = 2026
draft = true
note = null
written: {"name":"proven","year":2026,"draft":true,"note":null}
long value: kept 127 of 159 characters (silently cut)
It will read as familiar. This is the most common shape such a thing takes in C: fixed-size arrays to hold it, char arrays to copy strings into, -1 plus a char err[] to report failure.
This is not bad code. It is code that needs someone to keep it. The last line of the demonstration shows the price: given a value longer than the container, 127 of 159 characters survived and the rest was cut silently. The single line if (i + 1 < cap) in take_string decided that, and the caller has no way of learning it happened.
| Place | What the code says | What the code does not say |
|---|---|---|
| Length of a value | char str[128] | What happens past 128 characters |
| Number of pairs | MAX_PAIRS 16 | Who notices the seventeenth pair |
| Failure | return -1 + err[] | What happens if the caller does not check |
| Numbers | strtol | That overflow must be read from errno |
| Memory | A static array | Whether the parser’s usage is visible outside |
Table 96.2
The right-hand column is this code’s oral tradition. It lives in comments, in convention and in someone’s memory — not in the types.
95.3 The proven edition
examples-en/ch95/json_proven.c
/* A tiny JSON — the proven edition. Same grammar, but containers and failures
are handled as contracts. */
#include <proven.h>
#include <stdio.h>
typedef enum { J_STR, J_NUM, J_BOOL, J_NULL } jkind;
/* Values are *borrowed* — a view into the source buffer, so nothing is copied.
The length travels with it, so there is no NUL and no truncation. */
typedef struct {
proven_u8str_view_t key;
jkind kind;
proven_u8str_view_t str; /* J_STR */
long long num; /* J_NUM */
bool boolean;
} jpair;
typedef struct {
jpair *pair; /* obtained from the arena */
proven_size_t count;
proven_size_t cap;
} jdoc;
/* Failure comes back as a value — together with where it stopped. */
typedef struct {
proven_err_t err;
proven_size_t at; /* byte offset of the error */
} jresult;
static bool is_ws(proven_byte_t c)
{
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
}
static proven_size_t skip_ws(proven_u8str_view_t t, proven_size_t i)
{
while (i < t.size && is_ws(t.ptr[i])) i++;
return i;
}
static bool lit_at(proven_u8str_view_t t, proven_size_t i, proven_u8str_view_t lit)
{
if (t.size - i < lit.size) return false;
for (proven_size_t k = 0; k < lit.size; k++)
if (t.ptr[i + k] != lit.ptr[k]) return false;
return true;
}
/* Slice the quoted string in place — with no container there is nothing to overflow. */
static jresult take_string(proven_u8str_view_t t, proven_size_t *i,
proven_u8str_view_t *out)
{
if (*i >= t.size || t.ptr[*i] != '"')
return (jresult){ PROVEN_ERR_INVALID_FORMAT, *i };
proven_size_t start = ++(*i);
while (*i < t.size && t.ptr[*i] != '"') {
if (t.ptr[*i] == '\\' && *i + 1 < t.size) (*i)++;
(*i)++;
}
if (*i >= t.size) return (jresult){ PROVEN_ERR_INVALID_FORMAT, *i };
*out = (proven_u8str_view_t){ .ptr = t.ptr + start, .size = *i - start };
(*i)++;
return (jresult){ PROVEN_OK, *i };
}
/* Integers are accumulated with overflow checks — nothing wraps silently. */
static jresult take_number(proven_u8str_view_t t, proven_size_t *i, long long *out)
{
proven_size_t start = *i;
bool neg = false;
if (*i < t.size && (t.ptr[*i] == '-' || t.ptr[*i] == '+')) {
neg = t.ptr[*i] == '-';
(*i)++;
}
if (*i >= t.size || t.ptr[*i] < '0' || t.ptr[*i] > '9')
return (jresult){ PROVEN_ERR_INVALID_FORMAT, start };
long long v = 0;
while (*i < t.size && t.ptr[*i] >= '0' && t.ptr[*i] <= '9') {
int d = t.ptr[*i] - '0';
if (PROVEN_CKD_MUL(&v, v, 10LL) || PROVEN_CKD_ADD(&v, v, (long long)d))
return (jresult){ PROVEN_ERR_OVERFLOW, start };
(*i)++;
}
*out = neg ? -v : v;
return (jresult){ PROVEN_OK, *i };
}
static jresult json_parse(proven_u8str_view_t text, proven_arena_t *arena,
proven_size_t cap, jdoc *out)
{
/* count x element size — check the multiplication first */
proven_size_t bytes;
if (PROVEN_CKD_MUL(&bytes, cap, sizeof(jpair)))
return (jresult){ PROVEN_ERR_OVERFLOW, 0 };
proven_result_mem_mut_t room = proven_arena_alloc(arena, bytes);
if (!proven_is_ok(room.err)) return (jresult){ room.err, 0 };
*out = (jdoc){ .pair = (jpair *)room.value.ptr, .count = 0, .cap = cap };
proven_size_t i = skip_ws(text, 0);
if (i >= text.size || text.ptr[i] != '{')
return (jresult){ PROVEN_ERR_INVALID_FORMAT, i };
i = skip_ws(text, i + 1);
if (i < text.size && text.ptr[i] == '}') return (jresult){ PROVEN_OK, i };
for (;;) {
if (out->count == out->cap) return (jresult){ PROVEN_ERR_NOMEM, i };
jpair *e = &out->pair[out->count];
jresult r = take_string(text, &i, &e->key);
if (!proven_is_ok(r.err)) return r;
i = skip_ws(text, i);
if (i >= text.size || text.ptr[i] != ':')
return (jresult){ PROVEN_ERR_INVALID_FORMAT, i };
i = skip_ws(text, i + 1);
if (i < text.size && text.ptr[i] == '"') {
e->kind = J_STR;
r = take_string(text, &i, &e->str);
if (!proven_is_ok(r.err)) return r;
} else if (lit_at(text, i, PROVEN_LIT("true"))) {
e->kind = J_BOOL; e->boolean = true; i += 4;
} else if (lit_at(text, i, PROVEN_LIT("false"))) {
e->kind = J_BOOL; e->boolean = false; i += 5;
} else if (lit_at(text, i, PROVEN_LIT("null"))) {
e->kind = J_NULL; i += 4;
} else {
e->kind = J_NUM;
r = take_number(text, &i, &e->num);
if (!proven_is_ok(r.err)) return r;
}
out->count++;
i = skip_ws(text, i);
if (i < text.size && text.ptr[i] == ',') { i = skip_ws(text, i + 1); continue; }
if (i < text.size && text.ptr[i] == '}') return (jresult){ PROVEN_OK, i };
return (jresult){ PROVEN_ERR_INVALID_FORMAT, i };
}
}
/* Writing appends to a growing string — you get failure, not truncation. */
static proven_err_t json_write(const jdoc *doc, proven_u8str_t *s)
{
proven_err_t e = proven_u8str_append(s, PROVEN_LIT("{"));
for (proven_size_t i = 0; proven_is_ok(e) && i < doc->count; i++) {
const jpair *p = &doc->pair[i];
if (i) e = proven_u8str_append(s, PROVEN_LIT(","));
if (proven_is_ok(e)) e = proven_u8str_append(s, PROVEN_LIT("\""));
if (proven_is_ok(e)) e = proven_u8str_append(s, p->key);
if (proven_is_ok(e)) e = proven_u8str_append(s, PROVEN_LIT("\":"));
if (!proven_is_ok(e)) break;
switch (p->kind) {
case J_STR:
e = proven_u8str_append(s, PROVEN_LIT("\""));
if (proven_is_ok(e)) e = proven_u8str_append(s, p->str);
if (proven_is_ok(e)) e = proven_u8str_append(s, PROVEN_LIT("\""));
break;
case J_NUM: {
char tmp[32];
int n = snprintf(tmp, sizeof tmp, "%lld", p->num);
e = proven_u8str_append(s, (proven_u8str_view_t){
.ptr = (const proven_byte_t *)tmp, .size = (proven_size_t)n });
break;
}
case J_BOOL:
e = proven_u8str_append(s, p->boolean ? PROVEN_LIT("true")
: PROVEN_LIT("false"));
break;
case J_NULL:
e = proven_u8str_append(s, PROVEN_LIT("null"));
break;
}
}
if (proven_is_ok(e)) e = proven_u8str_append(s, PROVEN_LIT("}"));
return e;
}
static void show(const jdoc *doc)
{
printf("pairs: %zu\n", (size_t)doc->count);
for (proven_size_t i = 0; i < doc->count; i++) {
const jpair *p = &doc->pair[i];
printf(" %-6.*s = ", (int)p->key.size, (const char *)p->key.ptr);
switch (p->kind) {
case J_STR: printf("\"%.*s\"\n", (int)p->str.size, (const char *)p->str.ptr); break;
case J_NUM: printf("%lld\n", p->num); break;
case J_BOOL: printf("%s\n", p->boolean ? "true" : "false"); break;
case J_NULL: printf("null\n"); break;
}
}
}
int main(void)
{
static proven_byte_t backing[4096];
proven_arena_t arena = proven_arena_create(
(proven_mem_mut_t){ .ptr = backing, .size = sizeof backing });
proven_allocator_t alloc = proven_arena_as_allocator(&arena);
proven_u8str_view_t text = PROVEN_LIT(
"{ \"name\": \"proven\", \"year\": 2026, \"draft\": true, \"note\": null }");
jdoc doc;
jresult r = json_parse(text, &arena, 16, &doc);
if (!proven_is_ok(r.err)) {
printf("parse failed at byte %zu (err %d)\n", (size_t)r.at, (int)r.err);
return 1;
}
show(&doc);
proven_result_u8str_t made = proven_u8str_create(alloc, 256);
if (!proven_is_ok(made.err)) return 1;
proven_u8str_t out = made.value;
if (proven_is_ok(json_write(&doc, &out))) {
proven_u8str_view_t v = proven_u8str_as_view(&out);
printf("\nwritten: %.*s\n", (int)v.size, (const char *)v.ptr);
}
/* Limits do not pass silently — they come back as values */
jdoc small;
proven_u8str_view_t two = PROVEN_LIT("{\"a\":1,\"b\":2,\"c\":3}");
jresult tight = json_parse(two, &arena, 2, &small);
printf("cap 2 for 3 pairs -> err %d at byte %zu (refused, not cut)\n",
(int)tight.err, (size_t)tight.at);
proven_u8str_view_t huge = PROVEN_LIT("{\"n\":999999999999999999999}");
jresult over = json_parse(huge, &arena, 4, &small);
printf("overflowing number -> err %d at byte %zu (refused, not wrapped)\n",
(int)over.err, (size_t)over.at);
return 0;
}
Output
pairs: 4
name = "proven"
year = 2026
draft = true
note = null
written: {"name":"proven","year":2026,"draft":true,"note":null}
cap 2 for 3 pairs -> err 1 at byte 13 (refused, not cut)
overflowing number -> err 9 at byte 5 (refused, not wrapped)
It reads the same grammar and writes the same result. But the right-hand column of that table has moved to the left.
Values are not copied. A string value is a proven_u8str_view_t — a borrowed slice pointing into the source buffer (chapter 88). With no container there is nothing to overflow and nothing to truncate. In exchange one contract appears: it is valid only while the source lives. That contract is written in the type’s name.
Failure arrives as a value. jresult returns a proven_err_t together with where it stopped. The last two lines of the demonstration are that in the flesh: when there is no room for another pair it refuses instead of trimming (err 1), and a number too large to hold is refused rather than wrapped (err 9).
The source of memory is a parameter. json_parse takes an arena (chapter 89). It does not know where the memory comes from and does not need to. Give it a static array and it runs without a heap; give it a heap allocator and it runs on the heap. Neither requires touching the parser.
Integer overflow is checked by hand. PROVEN_CKD_MUL and PROVEN_CKD_ADD check at every carry. Not “return, then look at errno” but failure as a value at the moment of overflow.
| Place | Plain C | proven |
|---|---|---|
| String value | Copied into char str[128] — cut if longer | Borrowed as a view — no cut, a lifetime contract |
| Number of pairs | Fixed MAX_PAIRS | A cap the caller chooses; NOMEM beyond it |
| Failure | -1 plus a written reason | proven_err_t plus the byte it stopped at |
| Number parsing | strtol + errno (easy to forget) | Checked arithmetic at every digit |
| Memory | Static array (the parser decides) | An arena (the caller decides) |
| Writing | snprintf — cut if it does not fit | A growing u8str — failure if it does not fit |
Table 96.3
95.4 One step further — nesting, without recursion
The two editions so far read one flat object. Real JSON nests. How is that usually written? Recursive descent: when a value turns out to be an object, call yourself again to read what is inside. It is short and it reads well.
That brevity has a price attached. The input decides the depth. Nest a thousand deep and a thousand frames pile up; nest a hundred thousand deep and the stack gives way. It is chapter 43 exactly — the stack is narrow (a few MiB usually), and when it overflows the program dies with no way to check for it. For a parser reading files other people wrote, that is an attack surface.
So the extended edition uses no recursion. Both parsing and output are loops driven by an explicit stack. Depth becomes the length of an array, so crossing the limit can be refused as a value instead of collapsing the stack.
examples-en/ch95/json_nested.c
/* A tiny JSON — the extended proven edition. Reads and writes nested objects
and arrays. No recursion: both parsing and output are loops driven by an
*explicit stack*. Every part comes from proven — the arena (a lump of
lifetime), the pool (recycled fixed-size nodes), the intrusive list (linking
children), the dynamic array (the stack), checked arithmetic (depth, count). */
#include <proven.h>
#include <stdio.h>
typedef enum { J_OBJ, J_ARR, J_STR, J_NUM, J_BOOL, J_NULL } jkind;
/* One node. Children are linked intrusively — no separate child array. */
typedef struct jnode {
jkind kind;
proven_u8str_view_t key; /* filled only for a member of an object */
proven_u8str_view_t str; /* J_STR */
long long num; /* J_NUM */
bool boolean; /* J_BOOL */
proven_list_t kids; /* children of J_OBJ / J_ARR */
proven_list_node_t link; /* the hook onto the parent's kids */
} jnode;
typedef struct { proven_err_t err; proven_size_t at; } jresult;
#define OK(pos) ((jresult){ PROVEN_OK, (pos) })
#define BAD(pos) ((jresult){ PROVEN_ERR_INVALID_FORMAT, (pos) })
/* -- lexing --------------------------------------------------------- */
static bool is_ws(proven_byte_t c)
{ return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
static proven_size_t skip_ws(proven_u8str_view_t t, proven_size_t i)
{ while (i < t.size && is_ws(t.ptr[i])) i++; return i; }
static bool lit_at(proven_u8str_view_t t, proven_size_t i, proven_u8str_view_t lit)
{
if (t.size - i < lit.size) return false;
for (proven_size_t k = 0; k < lit.size; k++)
if (t.ptr[i + k] != lit.ptr[k]) return false;
return true;
}
static jresult take_string(proven_u8str_view_t t, proven_size_t *i,
proven_u8str_view_t *out)
{
if (*i >= t.size || t.ptr[*i] != '"') return BAD(*i);
proven_size_t start = ++(*i);
while (*i < t.size && t.ptr[*i] != '"') {
if (t.ptr[*i] == '\\' && *i + 1 < t.size) (*i)++;
(*i)++;
}
if (*i >= t.size) return BAD(*i);
*out = (proven_u8str_view_t){ .ptr = t.ptr + start, .size = *i - start };
(*i)++;
return OK(*i);
}
static jresult take_number(proven_u8str_view_t t, proven_size_t *i, long long *out)
{
proven_size_t start = *i;
bool neg = false;
if (*i < t.size && (t.ptr[*i] == '-' || t.ptr[*i] == '+')) { neg = t.ptr[*i] == '-'; (*i)++; }
if (*i >= t.size || t.ptr[*i] < '0' || t.ptr[*i] > '9') return BAD(start);
long long v = 0;
while (*i < t.size && t.ptr[*i] >= '0' && t.ptr[*i] <= '9') {
if (PROVEN_CKD_MUL(&v, v, 10LL) ||
PROVEN_CKD_ADD(&v, v, (long long)(t.ptr[*i] - '0')))
return (jresult){ PROVEN_ERR_OVERFLOW, start };
(*i)++;
}
*out = neg ? -v : v;
return OK(*i);
}
/* -- parser: a loop with an explicit stack, no recursion ------------- */
typedef struct { jnode *node; bool first; } frame;
typedef struct {
proven_allocator_t nodes; /* pool — recycles fixed-size nodes */
proven_array_t stack; /* the open containers (explicit stack) */
proven_size_t max_depth;
jnode *root;
proven_size_t count;
} jparser;
static jnode *node_new(jparser *p, jkind k)
{
proven_result_mem_mut_t m =
p->nodes.alloc_fn(p->nodes.ctx, sizeof(jnode), alignof(jnode));
if (!proven_is_ok(m.err)) return nullptr;
jnode *n = (jnode *)m.value.ptr;
*n = (jnode){ .kind = k };
proven_list_init(&n->kids);
p->count++;
return n;
}
static jresult parse(jparser *p, proven_u8str_view_t t)
{
proven_size_t i = skip_ws(t, 0);
p->root = nullptr;
for (;;) {
/* read one value */
proven_u8str_view_t key = { .ptr = nullptr, .size = 0 };
bool in_obj = false;
if (p->stack.len > 0) {
frame *top = (frame *)proven_array_get_mut(&p->stack, p->stack.len - 1);
in_obj = top->node->kind == J_OBJ;
if (!top->first) { /* a comma from the second on */
if (i < t.size && t.ptr[i] == ',') i = skip_ws(t, i + 1);
else if (i < t.size && (t.ptr[i] == '}' || t.ptr[i] == ']')) goto close;
else return BAD(i);
} else if (i < t.size && (t.ptr[i] == '}' || t.ptr[i] == ']')) {
goto close; /* an empty container */
}
if (in_obj) { /* in an object a key comes first */
jresult r = take_string(t, &i, &key);
if (!proven_is_ok(r.err)) return r;
i = skip_ws(t, i);
if (i >= t.size || t.ptr[i] != ':') return BAD(i);
i = skip_ws(t, i + 1);
}
}
if (i >= t.size) return BAD(i);
jnode *n = nullptr;
proven_byte_t c = t.ptr[i];
if (c == '{' || c == '[') {
n = node_new(p, c == '{' ? J_OBJ : J_ARR);
if (!n) return (jresult){ PROVEN_ERR_NOMEM, i };
n->key = key;
} else if (c == '"') {
n = node_new(p, J_STR);
if (!n) return (jresult){ PROVEN_ERR_NOMEM, i };
n->key = key;
jresult r = take_string(t, &i, &n->str);
if (!proven_is_ok(r.err)) return r;
} else if (lit_at(t, i, PROVEN_LIT("true")) || lit_at(t, i, PROVEN_LIT("false"))) {
n = node_new(p, J_BOOL);
if (!n) return (jresult){ PROVEN_ERR_NOMEM, i };
n->key = key;
n->boolean = c == 't';
i += (c == 't') ? 4 : 5;
} else if (lit_at(t, i, PROVEN_LIT("null"))) {
n = node_new(p, J_NULL);
if (!n) return (jresult){ PROVEN_ERR_NOMEM, i };
n->key = key;
i += 4;
} else {
n = node_new(p, J_NUM);
if (!n) return (jresult){ PROVEN_ERR_NOMEM, i };
n->key = key;
jresult r = take_number(t, &i, &n->num);
if (!proven_is_ok(r.err)) return r;
}
/* hook it onto the parent — intrusive, so no child array is needed */
if (p->stack.len > 0) {
frame *top = (frame *)proven_array_get_mut(&p->stack, p->stack.len - 1);
proven_list_push_back(&top->node->kids, &n->link);
top->first = false;
} else {
p->root = n;
}
if (n->kind == J_OBJ || n->kind == J_ARR) {
/* depth is capped *as a value* — we do not wait for a stack overflow */
if (p->stack.len >= p->max_depth)
return (jresult){ PROVEN_ERR_OUT_OF_BOUNDS, i };
frame f = { .node = n, .first = true };
proven_err_t e = proven_array_push(&p->stack, &f);
if (!proven_is_ok(e)) return (jresult){ e, i };
i = skip_ws(t, i + 1);
continue;
}
i = skip_ws(t, i);
if (p->stack.len == 0) return OK(i); /* a lone scalar was the whole document */
continue;
close:
{
frame done;
proven_err_t e = proven_array_pop(&p->stack, &done);
if (!proven_is_ok(e)) return (jresult){ e, i };
proven_byte_t want = done.node->kind == J_OBJ ? '}' : ']';
if (i >= t.size || t.ptr[i] != want) return BAD(i);
i = skip_ws(t, i + 1);
if (p->stack.len == 0) return OK(i);
continue;
}
}
}
/* -- output: again without recursion, on its own stack --------------- */
typedef struct { jnode *node; proven_list_node_t *next; bool opened; } oframe;
static proven_err_t emit_scalar(proven_u8str_t *s, const jnode *n)
{
switch (n->kind) {
case J_STR: {
proven_err_t e = proven_u8str_append(s, PROVEN_LIT("\""));
if (proven_is_ok(e)) e = proven_u8str_append(s, n->str);
if (proven_is_ok(e)) e = proven_u8str_append(s, PROVEN_LIT("\""));
return e;
}
case J_NUM: {
char tmp[32];
int k = snprintf(tmp, sizeof tmp, "%lld", n->num);
return proven_u8str_append(s, (proven_u8str_view_t){
.ptr = (const proven_byte_t *)tmp, .size = (proven_size_t)k });
}
case J_BOOL:
return proven_u8str_append(s, n->boolean ? PROVEN_LIT("true") : PROVEN_LIT("false"));
case J_NULL:
return proven_u8str_append(s, PROVEN_LIT("null"));
default:
return PROVEN_ERR_INVALID_STATE;
}
}
static proven_err_t emit_key(proven_u8str_t *s, const jnode *n)
{
if (n->key.ptr == nullptr) return PROVEN_OK;
proven_err_t e = proven_u8str_append(s, PROVEN_LIT("\""));
if (proven_is_ok(e)) e = proven_u8str_append(s, n->key);
if (proven_is_ok(e)) e = proven_u8str_append(s, PROVEN_LIT("\":"));
return e;
}
static proven_err_t write_iter(jnode *root, proven_allocator_t alloc,
proven_size_t max_depth, proven_u8str_t *s)
{
proven_result_array_t made = PROVEN_ARRAY_INIT(alloc, oframe, 8);
if (!proven_is_ok(made.err)) return made.err;
proven_array_t st = made.value;
proven_err_t e = PROVEN_OK;
oframe f0 = { .node = root, .next = nullptr, .opened = false };
e = proven_array_push(&st, &f0);
while (proven_is_ok(e) && st.len > 0) {
oframe *f = (oframe *)proven_array_get_mut(&st, st.len - 1);
jnode *n = f->node;
if (!f->opened) {
e = emit_key(s, n);
if (!proven_is_ok(e)) break;
if (n->kind != J_OBJ && n->kind != J_ARR) {
e = emit_scalar(s, n);
oframe drop; (void)proven_array_pop(&st, &drop);
continue;
}
e = proven_u8str_append(s, n->kind == J_OBJ ? PROVEN_LIT("{") : PROVEN_LIT("["));
f->opened = true;
f->next = n->kids.head.next;
continue;
}
if (f->next == &n->kids.head) { /* all children emitted */
e = proven_u8str_append(s, n->kind == J_OBJ ? PROVEN_LIT("}") : PROVEN_LIT("]"));
oframe drop; (void)proven_array_pop(&st, &drop);
continue;
}
jnode *kid = PROVEN_LIST_ENTRY(f->next, jnode, link);
bool first = f->next == n->kids.head.next;
f->next = f->next->next;
if (!first) e = proven_u8str_append(s, PROVEN_LIT(","));
if (!proven_is_ok(e)) break;
if (st.len >= max_depth) { e = PROVEN_ERR_OUT_OF_BOUNDS; break; }
oframe kf = { .node = kid, .next = nullptr, .opened = false };
e = proven_array_push(&st, &kf);
}
proven_array_destroy(&st);
return e;
}
/* -- demonstration --------------------------------------------------- */
static void run(const char *label, proven_u8str_view_t text,
proven_allocator_t backing, proven_size_t max_depth)
{
proven_pool_t pool;
if (!proven_is_ok(proven_pool_init(&pool, backing, sizeof(jnode),
alignof(jnode), 64))) return;
proven_result_array_t st = PROVEN_ARRAY_INIT(backing, frame, 8);
if (!proven_is_ok(st.err)) { proven_pool_destroy(&pool); return; }
jparser p = { .nodes = proven_pool_as_allocator(&pool), .stack = st.value,
.max_depth = max_depth, .count = 0 };
jresult r = parse(&p, text);
printf("%-22s ", label);
if (!proven_is_ok(r.err)) {
printf("refused: err %d at byte %zu (depth limit %zu)\n",
(int)r.err, (size_t)r.at, (size_t)max_depth);
} else {
proven_result_u8str_t made = proven_u8str_create(backing, 512);
if (proven_is_ok(made.err)) {
proven_u8str_t out = made.value;
proven_err_t e = write_iter(p.root, backing, max_depth, &out);
proven_u8str_view_t v = proven_u8str_as_view(&out);
if (proven_is_ok(e))
printf("%zu nodes -> %.*s\n", (size_t)p.count,
(int)v.size, (const char *)v.ptr);
else
printf("write refused: err %d\n", (int)e);
proven_u8str_destroy(backing, &out);
}
}
proven_array_destroy(&p.stack);
proven_pool_destroy(&pool);
}
int main(void)
{
static proven_byte_t backing_mem[64 * 1024];
proven_arena_t arena = proven_arena_create(
(proven_mem_mut_t){ .ptr = backing_mem, .size = sizeof backing_mem });
proven_allocator_t alloc = proven_arena_as_allocator(&arena);
run("flat object", PROVEN_LIT(
"{\"name\":\"proven\",\"year\":2026}"), alloc, 32);
run("nested", PROVEN_LIT(
"{\"book\":{\"title\":\"Proven C\",\"parts\":13},"
"\"tags\":[\"c23\",\"systems\"],\"draft\":true}"), alloc, 32);
run("array of objects", PROVEN_LIT(
"[{\"id\":1,\"ok\":true},{\"id\":2,\"ok\":false},[]]"), alloc, 32);
/* input 200 deep — where a recursive parser would blow the stack */
static char deep[512];
proven_size_t n = 0;
for (int k = 0; k < 200; k++) deep[n++] = '[';
for (int k = 0; k < 200; k++) deep[n++] = ']';
proven_u8str_view_t deep_view = {
.ptr = (const proven_byte_t *)deep, .size = n };
run("depth 200, limit 32", deep_view, alloc, 32);
run("depth 200, limit 256", deep_view, alloc, 256);
return 0;
}
Output
flat object 3 nodes -> {"name":"proven","year":2026}
nested 8 nodes -> {"book":{"title":"Proven C","parts":13},"tags":["c23","systems"],"draft":true}
array of objects 8 nodes -> [{"id":1,"ok":true},{"id":2,"ok":false},[]]
depth 200, limit 32 refused: err 2 at byte 32 (depth limit 32)
depth 200, limit 256 200 nodes -> [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
The last two lines of the output are the point of the design. Given the same 200-deep input, a limit of 32 refuses at the 32nd level (err 2), and a limit of 256 reads it through and builds 200 nodes. Neither run dies — depth is a setting, not an incident.
95.4.1 What was used where
This edition draws on the tools of Part XII across the board. Gathered in one place, each takes on one problem.
| Tool | What it takes on | Without it |
|---|---|---|
| Arena (chapter 89) | Takes the memory of one parse in a lump and drops it in a lump | Every node needs a matching free |
| Pool (chapter 89) | Recycles slots of exactly one jnode | Same-size allocations fragment the heap |
| Intrusive list (chapter 88) | Hooks a child onto its parent — through a link inside the node | A separate child array must be allocated and grown |
| Dynamic array | Stacks the open containers — the explicit stack | You end up leaning on the call stack (that is, recursion) |
view (chapter 88) | Borrows keys and strings from the source | Every character needs a copy and a container |
| Checked arithmetic (chapter 88) | Watches overflow at every carry | One forgotten errno and it wraps |
proven_err_t (chapter 87) | Depth exceeded, no room, bad syntax — all as values | Either death, or a silent trim |
Table 96.4
The intrusive list earns its keep especially here. Rather than allocating an array for the children, each node carries one link (proven_list_node_t link) that threads it onto the parent’s list. The link was created along with the node, so adding a child costs no new allocation — and one more place that could fail disappears.
Q. Does dropping recursion not make the code longer and harder to read?
A. Longer, yes. What would be ten lines in a recursive version becomes thirty of stack frames and state transitions. Recursion also reads more easily — it is closer to the model in a person’s head.
It is still written this way for one reason: the input must not decide how much resource is consumed. In the recursive version depth eats the call stack, an invisible resource that can be neither checked nor capped. With an explicit stack, depth is stack.len — a number you can see — and the cap is a parameter.
Out of that comes the working rule for code at a boundary (files, networks, plug-ins): do not read a format with depth using recursion. If you do, put a limit on the depth and count it.
In practice. Deep nesting is a real attack
“Depth bombs” are an old class of attack on JSON and XML parsers. Send a few kilobytes with a hundred thousand brackets in it and a recursive parser overflows the stack while reading it and the process dies — denial of service. Stopping a server with a few dozen bytes of input is a good return on effort.
That is why widely used parsers nearly all impose a depth limit. It is also why this example takes max_depth as a parameter — and why the limit is set by the caller rather than the library, since what counts as reasonable differs from one place of use to another.
A common misconception. “Removing recursion removes stack overflow”
It does not remove it — it moves it. An explicit stack eats memory too. The difference is that this memory sits on the heap (or in an arena), its length can be counted, and a cap can be placed on it.
The point is not “recursion is bad” but keep the resource where you can see it. If the depth is a constant you chose (as in code reading your own config file), recursion is the better choice. If someone else chooses the depth, it is better to hold that resource in your hand and count it.
95.5 So what actually changed
Q. The proven edition is the longer one. Where is the gain?
A. What it is longer by is the checking that should have been there. The plain edition’s brevity was bought by not checking, and that checking did not vanish — it moved onto a person.
Count the difference and it comes to this. In the plain edition there are five places a person must remember to be careful: the container size, the maximum pair count, checking the return value, checking errno, and the size of the static array. In the proven edition those five moved into types, parameters and return values. What had to be remembered became what can be read — that is the gain.
And it grows with the code. Five things can be remembered in a 200-line parser. They cannot be remembered in a 20,000-line program.
Q. Is the plain edition useless, then?
A. No — and this distinction is the most important thing in the chapter.
The plain edition is excellent when the conditions are narrow: input you made yourself, sizes you know, code that never leaves this program. There its brevity is the virtue. What is dangerous is when that code crosses a boundary. The moment it reads a file someone else wrote, or bytes off a network, or runs inside a long-lived program, all five unwritten things become seeds of an incident.
That is why chapter 85′s five bugs have been shipping for half a century. Not because the code was bad, but because code written for narrow conditions moved somewhere wide.
A common misconception. “Using a library stops you making these mistakes”
A library does not stop mistakes. It exposes them. Ignore the jresult in the proven edition and the outcome matches the plain edition — except that writing it that way is more awkward, and where [[nodiscard]] is attached the compiler speaks up (chapter 87).
What a tool can do ends at making the correct path the easy path. Beyond that it is always the user’s part — which is also why this book explained the problems before it introduced the library.
In practice. Where a real JSON parser gets harder
What all three editions left out is where the real difficulty lives. \u escapes must handle UTF-16 surrogate pairs (chapter 9), and reals bring along the rounding problems of chapter 8 — read 0.1 and write it back, and do the same characters come out?
That is why widely used parsers run to thousands of lines, and it is worth remembering that a good share of those lines are not features but boundaries.
Recap
| What to keep | The point |
|---|---|
| One program, two editions | Not the syntax but what gets written down differs |
| Plain C | Short. The price of that brevity is five things a person must remember |
| proven | Longer. It is longer by the checks moved into types, returns and parameters |
| Cut versus refuse | Failure comes back as a value instead of a silent trim |
| Source of memory | The caller provides it; the parser does not decide |
| Boundaries | Code written for narrow conditions gets dangerous somewhere wide |
| Nesting and depth | An explicit stack instead of recursion — depth becomes a setting, not an incident |
Table 96.5
Part XII ends here. We have seen the five contracts one at a time, and finally watched all five meet inside one program, written three times. The last part closes the book — C in practice, the embedded toolbox, and everything gathered up.