Proven C Book한국어 GitHub

95 Writing it three times — a tiny JSON

What to know first

chapter 87, Errors are values · returning failure as a value
chapter 88, Foundations — byte, view, checked arithmetic · borrowed slices and overflow checks
chapter 89, Allocation — allocators, arenas, pools · where memory comes from
chapter 90, Strings · refuse rather than truncate

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

The part closes not with explanation but with contrast. Writing the same program twice, in plain C and with proven, is what makes the decisions explained one by one over the previous ten chapters visible at a glance in the code. The contrast only works once every individual feature has been seen.

By the end of this chapter

The chapter that closes Part XII — an exercise without exercises. We write a very small JSON reader and writer in three editions — plain C, proven, and an extended proven that takes nesting — and watch where they part company. The last one shows how to handle depth without recursion. No new syntax appears. What appears is choice.

The questions this chapter answers

  1. Does dropping recursion not make the code longer and harder to read?
  2. The proven edition is the longer one. Where is the gain?
  3. 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.

InOut
One flat object — { "key": value, ... }Nesting (in the first two editions; the third solves it)
Four kinds of value — string, integer, boolean, nullReals, exponent notation
Reading and writing back (a round trip)\u escapes, comments
Where a failure happenedRecovery, 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.

PlaceWhat the code saysWhat the code does not say
Length of a valuechar str[128]What happens past 128 characters
Number of pairsMAX_PAIRS 16Who notices the seventeenth pair
Failurereturn -1 + err[]What happens if the caller does not check
NumbersstrtolThat overflow must be read from errno
MemoryA static arrayWhether 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.

PlacePlain Cproven
String valueCopied into char str[128] — cut if longerBorrowed as a view — no cut, a lifetime contract
Number of pairsFixed MAX_PAIRSA cap the caller chooses; NOMEM beyond it
Failure-1 plus a written reasonproven_err_t plus the byte it stopped at
Number parsingstrtol + errno (easy to forget)Checked arithmetic at every digit
MemoryStatic array (the parser decides)An arena (the caller decides)
Writingsnprintf — cut if it does not fitA 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.

ToolWhat it takes onWithout it
Arena (chapter 89)Takes the memory of one parse in a lump and drops it in a lumpEvery node needs a matching free
Pool (chapter 89)Recycles slots of exactly one jnodeSame-size allocations fragment the heap
Intrusive list (chapter 88)Hooks a child onto its parent — through a link inside the nodeA separate child array must be allocated and grown
Dynamic arrayStacks the open containers — the explicit stackYou end up leaning on the call stack (that is, recursion)
view (chapter 88)Borrows keys and strings from the sourceEvery character needs a copy and a container
Checked arithmetic (chapter 88)Watches overflow at every carryOne forgotten errno and it wraps
proven_err_t (chapter 87)Depth exceeded, no room, bad syntax — all as valuesEither 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 keepThe point
One program, two editionsNot the syntax but what gets written down differs
Plain CShort. The price of that brevity is five things a person must remember
provenLonger. It is longer by the checks moved into types, returns and parameters
Cut versus refuseFailure comes back as a value instead of a silent trim
Source of memoryThe caller provides it; the parser does not decide
BoundariesCode written for narrow conditions gets dangerous somewhere wide
Nesting and depthAn 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.