From dfcac6433079e7951099afe784dd67f8d8786bd0 Mon Sep 17 00:00:00 2001 From: Andrew Noyes Date: Sun, 14 Jun 2026 23:46:15 -0400 Subject: [PATCH] Add JSON Schema -> C++ streaming parser generator Generates a C++ type and a streaming builder parser from a JSON Schema, built on weaseljson. The builder copies incoming bytes directly into their final destinations in the result struct (no intermediate DOM) and hands back ownership via take() once parsing completes. Supports objects/structs, required vs optional (std::optional) fields, nullable types, string enums, arrays, $ref including recursion (broken with unique_ptr), and additionalProperties (strict reject or skip). Unsupported schema constructs are rejected at generation time; schema violations are rejected at parse time. --- contrib/schemagen/README.md | 75 ++ contrib/schemagen/example.schema.json | 99 ++ contrib/schemagen/test_gen.cpp | 157 ++++ contrib/schemagen/weaseljson_schemagen.py | 1026 +++++++++++++++++++++ 4 files changed, 1357 insertions(+) create mode 100644 contrib/schemagen/README.md create mode 100644 contrib/schemagen/example.schema.json create mode 100644 contrib/schemagen/test_gen.cpp create mode 100644 contrib/schemagen/weaseljson_schemagen.py diff --git a/contrib/schemagen/README.md b/contrib/schemagen/README.md new file mode 100644 index 0000000..5418333 --- /dev/null +++ b/contrib/schemagen/README.md @@ -0,0 +1,75 @@ +# weaseljson schemagen + +Generates a C++ type and a streaming **builder** parser from a JSON Schema, +on top of weaseljson. + +The builder copies incoming bytes straight into their final destinations in the +result struct as they arrive (no intermediate DOM). When parsing completes you +`take()` ownership of the result. + +```sh +python3 weaseljson_schemagen.py schema.json -o parsed.h --namespace myschema +``` + +## Usage + +```cpp +#include "parsed.h" +using namespace myschema; + +RootBuilder b; +WeaselJsonStatus s = b.feed(buf, len); // call repeatedly with chunks; buf may + // be modified in place (unescaping) +if (s == WeaselJson_AGAIN) s = b.finish(); +if (s == WeaselJson_OK) { + Root value = b.take(); // ownership of the parsed result +} +``` + +`feed`/`finish` return the usual `WeaselJsonStatus`; `WeaselJson_OK` means the +document is both valid JSON and schema-valid, and `WeaselJson_REJECT` covers +both malformed JSON and schema violations. The builder holds interior pointers +into the result, so it is non-movable. + +## Schema -> C++ mapping + +| JSON Schema | C++ | +|-----------------------------------------------|----------------------------------------| +| `object` with `properties` | `struct` | +| required property | value member | +| non-required property | `std::optional` | +| `["T", "null"]` (nullable) | `std::optional` (accepts `null`) | +| `string` / `integer` / `number` / `boolean` | `std::string` / `int64_t` / `double` / `bool` | +| `enum` of strings | `enum class : int` | +| `array` with `items` | `std::vector` | +| `$ref` to `$defs`/`definitions` | the referenced named struct | +| recursive `$ref` | `std::unique_ptr` (cycle broken) | +| `additionalProperties: false` | unknown keys rejected | +| `additionalProperties` absent / `true` | unknown keys' values skipped | + +## Schema violations (rejected at parse time) + +- missing required property (top-level or nested) +- wrong JSON type for a property / array element +- `null` for a non-nullable slot +- value not in a string `enum` +- a number not representable in the target type (e.g. `1.5` for an `integer`) +- duplicate object keys +- unknown key under `additionalProperties: false` + +## Not supported (rejected at generation time, no fallback) + +`oneOf` / `anyOf` / `allOf` / `not` / `if`-`then`-`else`, +`patternProperties`, `additionalProperties` with a schema (typed map), +`prefixItems` (tuples), `const`, `dependentSchemas`/`dependentRequired`, +union `type` lists other than `["T", "null"]`, non-string enums, and remote +(`$ref` to other documents). + +## Notes + +- Strings (the bulk payload) are appended directly into their destination + `std::string`, even when split across `feed` calls. Numbers are accumulated in + a small scratch buffer and converted on completion, since an `int64_t`/`double` + cannot hold partial digits. +- `test_gen.cpp` generates from `example.schema.json` and exercises the parser + byte-by-byte (covering chunked strings/numbers), plus the rejection cases. diff --git a/contrib/schemagen/example.schema.json b/contrib/schemagen/example.schema.json new file mode 100644 index 0000000..1bd6180 --- /dev/null +++ b/contrib/schemagen/example.schema.json @@ -0,0 +1,99 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "age" + ], + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "integer" + }, + "height": { + "type": "number" + }, + "active": { + "type": "boolean" + }, + "nickname": { + "type": [ + "string", + "null" + ] + }, + "role": { + "enum": [ + "admin", + "user", + "guest" + ] + }, + "hobbies": { + "type": "array", + "items": { + "type": "string" + } + }, + "address": { + "type": "object", + "required": [ + "city" + ], + "additionalProperties": false, + "properties": { + "city": { + "type": "string" + }, + "zip": { + "type": "integer" + } + } + }, + "friends": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "integer" + } + } + } + }, + "tree": { + "$ref": "#/$defs/Node" + } + }, + "$defs": { + "Node": { + "type": "object", + "additionalProperties": false, + "required": [ + "value" + ], + "properties": { + "value": { + "type": "integer" + }, + "next": { + "$ref": "#/$defs/Node" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/Node" + } + } + } + } + } +} diff --git a/contrib/schemagen/test_gen.cpp b/contrib/schemagen/test_gen.cpp new file mode 100644 index 0000000..ac36de2 --- /dev/null +++ b/contrib/schemagen/test_gen.cpp @@ -0,0 +1,157 @@ +// Test harness for the generated example.schema.json parser. +// Feeds input one byte at a time to exercise chunked string/number paths. +#include +#include +#include + +#include "gen.h" + +using namespace weasel_schema; + +// Feed `in` one byte at a time. Returns final status. +static WeaselJsonStatus parseStrided(RootBuilder &b, std::string in) { + for (size_t i = 0; i < in.size(); ++i) { + char c = in[i]; + WeaselJsonStatus s = b.feed(&c, 1); + if (s != WeaselJson_AGAIN) + return s; + } + return b.finish(); +} + +static int failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++failures; \ + } \ + } while (0) + +static void expectReject(const std::string &json, const char *what) { + RootBuilder b; + WeaselJsonStatus s = parseStrided(b, json); + if (s == WeaselJson_REJECT) { + printf("ok reject: %s\n", what); + } else { + printf("FAIL expected reject (%s) got status %d for: %s\n", what, s, + json.c_str()); + ++failures; + } +} + +int main() { + // ---- happy path, full feature coverage, byte-strided ---- + { + std::string json = R"({ + "name": "Ada É", + "age": 36, + "height": 1.75, + "active": true, + "nickname": null, + "role": "admin", + "hobbies": ["math", "lace"], + "address": { "city": "London", "zip": 12345 }, + "friends": [ + { "name": "Bob" }, + { "name": "Cay", "age": 40 } + ], + "tree": { + "value": 1, + "children": [ { "value": 2 }, { "value": 3 } ], + "next": { "value": 99 } + }, + "extra_unknown": { "ignored": [1, 2, {"x": "y"}] } + })"; + // Root is strict (additionalProperties:false), so "extra_unknown" must + // make it reject. Verify that, then test a clean version. + expectReject(json, "unknown key in strict root"); + } + + { + std::string json = R"({ + "name": "Ada É", + "age": 36, + "height": 1.75, + "active": true, + "nickname": null, + "role": "admin", + "hobbies": ["math", "lace"], + "address": { "city": "London", "zip": 12345 }, + "friends": [ + { "name": "Bob" }, + { "name": "Cay", "age": 40 } + ], + "tree": { + "value": 1, + "children": [ { "value": 2 }, { "value": 3 } ], + "next": { "value": 99 } + } + })"; + RootBuilder b; + WeaselJsonStatus s = parseStrided(b, json); + CHECK(s == WeaselJson_OK); + if (s == WeaselJson_OK) { + Root r = b.take(); + CHECK(r.name == "Ada \xC3\x89"); // É -> UTF-8 + CHECK(r.age == 36); + CHECK(r.height > 1.74 && r.height < 1.76); + CHECK(r.active.has_value() && *r.active == true); + CHECK(!r.nickname.has_value()); // explicit null + CHECK(r.role.has_value() && *r.role == Role::admin); + CHECK(r.hobbies.has_value() && r.hobbies->size() == 2); + CHECK(r.hobbies && (*r.hobbies)[0] == "math" && + (*r.hobbies)[1] == "lace"); + CHECK(r.address.has_value() && r.address->city == "London"); + CHECK(r.address && r.address->zip.has_value() && + *r.address->zip == 12345); + CHECK(r.friends.has_value() && r.friends->size() == 2); + CHECK(r.friends && (*r.friends)[0].name == "Bob"); + CHECK(r.friends && !(*r.friends)[0].age.has_value()); + CHECK(r.friends && (*r.friends)[1].age.has_value() && + *(*r.friends)[1].age == 40); + CHECK(r.tree.has_value() && r.tree->value == 1); + CHECK(r.tree && r.tree->children.has_value() && + r.tree->children->size() == 2); + CHECK(r.tree && r.tree->children && (*r.tree->children)[0].value == 2); + CHECK(r.tree && r.tree->children && (*r.tree->children)[1].value == 3); + CHECK(r.tree && r.tree->next && r.tree->next->value == 99); + printf("ok happy path (byte-strided)\n"); + } + } + + // ---- whole-buffer feed (not strided) ---- + { + std::string json = R"({"name":"x","age":7})"; + RootBuilder b; + WeaselJsonStatus s = b.feed(json.data(), (int)json.size()); + if (s == WeaselJson_AGAIN) + s = b.finish(); + CHECK(s == WeaselJson_OK); + Root r = b.take(); + CHECK(r.name == "x" && r.age == 7); + CHECK(!r.role.has_value() && !r.hobbies.has_value()); + printf("ok minimal object\n"); + } + + // ---- schema-violation rejections ---- + expectReject(R"({"name":"x"})", "missing required field 'age'"); + expectReject(R"({"name":"x","age":"notnum"})", "wrong type (string for int)"); + expectReject(R"({"name":"x","age":1,"role":"boss"})", "bad enum value"); + expectReject(R"({"name":"x","age":1,"name":"y"})", "duplicate key"); + expectReject(R"({"name":"x","age":1,"active":null})", + "null for non-nullable"); + expectReject(R"({"name":"x","age":1,"age":2})", "duplicate required key"); + expectReject(R"({"name":"x","age":1.5})", "fractional for integer"); + expectReject(R"({"name":"x","age":1,"address":{"zip":5}})", + "missing required nested 'city'"); + expectReject(R"([1,2,3])", "array where object expected (root)"); + expectReject(R"({"name":"x","age":1,)", "truncated / invalid json"); + + if (failures == 0) { + printf("\nALL TESTS PASSED\n"); + return 0; + } + printf("\n%d FAILURE(S)\n", failures); + return 1; +} diff --git a/contrib/schemagen/weaseljson_schemagen.py b/contrib/schemagen/weaseljson_schemagen.py new file mode 100644 index 0000000..6f751df --- /dev/null +++ b/contrib/schemagen/weaseljson_schemagen.py @@ -0,0 +1,1026 @@ +#!/usr/bin/env python3 +"""Generate a C++ type + streaming parser from a JSON Schema, using weaseljson. + +The generated parser is a "builder": you feed it bytes incrementally and it +writes them straight into their final destinations in the result struct. When +parsing completes you take() ownership of the result. + +Only the subset of JSON Schema that maps naturally onto C++ structs is +supported. Anything else is rejected at generation time (no JsonValue fallback). + +Usage: + weaseljson_schemagen.py schema.json [-o out.h] [--namespace ns] +""" + +import argparse +import json +import keyword +import sys + + +class GenError(Exception): + pass + + +# --------------------------------------------------------------------------- +# Type model +# --------------------------------------------------------------------------- +class TScalar: + def __init__(self, kind): # 'str' | 'int' | 'dbl' | 'bool' + self.kind = kind + + +class TEnum: + def __init__(self, name): + self.name = name + + +class TObj: + def __init__(self, name): + self.name = name + + +class TArr: + def __init__(self, elem, elem_nullable): + self.elem = elem + self.elem_nullable = elem_nullable + + +class Field: + def __init__(self, key, cpp, ty, required, nullable): + self.key = key # JSON key + self.cpp = cpp # C++ member name + self.ty = ty + self.required = required + self.nullable = nullable + self.unique = False # set during recursion breaking + + +class ObjectType: + def __init__(self, name): + self.name = name + self.fields = [] # list[Field] + self.strict = False # additionalProperties: false + + +class EnumType: + def __init__(self, name, values): + self.name = name + self.values = values # list[str] + + +# --------------------------------------------------------------------------- +# Identifier helpers +# --------------------------------------------------------------------------- +def sanitize(name, fallback="x"): + out = [] + for ch in name: + out.append(ch if (ch.isalnum() or ch == "_") else "_") + s = "".join(out) + if not s: + s = fallback + if s[0].isdigit(): + s = "_" + s + if keyword.iskeyword(s) or s in _CPP_KEYWORDS: + s = s + "_" + return s + + +def camel(name): + parts = [p for p in name.replace("-", " ").replace("_", " ").split(" ") if p] + if not parts: + return "T" + return "".join(p[:1].upper() + p[1:] for p in parts) + + +_CPP_KEYWORDS = { + "alignas", + "alignof", + "and", + "asm", + "auto", + "bool", + "break", + "case", + "catch", + "char", + "class", + "const", + "constexpr", + "continue", + "decltype", + "default", + "delete", + "do", + "double", + "else", + "enum", + "explicit", + "export", + "extern", + "false", + "float", + "for", + "friend", + "goto", + "if", + "inline", + "int", + "long", + "namespace", + "new", + "not", + "nullptr", + "operator", + "or", + "private", + "protected", + "public", + "register", + "return", + "short", + "signed", + "sizeof", + "static", + "struct", + "switch", + "template", + "this", + "throw", + "true", + "try", + "typedef", + "typename", + "union", + "unsigned", + "using", + "virtual", + "void", + "volatile", + "while", +} + + +# --------------------------------------------------------------------------- +# Schema -> type model +# --------------------------------------------------------------------------- +class Builder: + def __init__(self, root_schema): + self.root_schema = root_schema + self.defs = {} + for key in ("$defs", "definitions"): + self.defs.update(root_schema.get(key, {})) + self.objects = {} # name -> ObjectType (insertion ordered) + self.enums = {} # name -> EnumType + self._building = {} # def name -> TObj/etc (for $ref cycles) + self._used_names = set() + + def unique_name(self, hint): + base = camel(hint) + name = base + i = 1 + while name in self._used_names: + i += 1 + name = f"{base}{i}" + self._used_names.add(name) + return name + + def ref_name(self, ref): + if not ref.startswith("#/"): + raise GenError(f"only local $ref supported, got: {ref}") + parts = ref[2:].split("/") + if len(parts) != 2 or parts[0] not in ("$defs", "definitions"): + raise GenError(f"unsupported $ref target: {ref}") + return parts[1] + + def build_def(self, defname): + """Build (or fetch cached) type for a named $defs entry.""" + if defname in self._building: + return self._building[defname] + if defname not in self.defs: + raise GenError(f"$ref to unknown def: {defname}") + node = self.defs[defname] + # For objects we must register the name before recursing into fields. + return self.build_type(node, defname, defname=defname) + + def build_type(self, node, hint, defname=None): + if not isinstance(node, dict): + raise GenError(f"schema node must be an object: {node!r}") + + for bad in ( + "allOf", + "oneOf", + "anyOf", + "not", + "if", + "then", + "else", + "patternProperties", + "prefixItems", + "const", + "dependentSchemas", + "dependentRequired", + ): + if bad in node: + raise GenError( + f"'{bad}' is not supported (no natural C++ struct mapping)" + ) + + if "$ref" in node: + return self.build_def(self.ref_name(node["$ref"])) + + # nullability via type lists: ["string", "null"] + nullable = False + typ = node.get("type") + if isinstance(typ, list): + non_null = [t for t in typ if t != "null"] + if "null" in typ: + nullable = True + if len(non_null) != 1: + raise GenError(f"union types are not supported: {typ!r}") + typ = non_null[0] + + # string enum + if "enum" in node: + vals = node["enum"] + if not vals or not all(isinstance(v, str) for v in vals): + raise GenError("only non-empty string enums are supported") + name = defname and self.unique_name(defname) or self.unique_name(hint) + self.enums[name] = EnumType(name, list(vals)) + return (TEnum(name), nullable) + + if typ == "object" or (typ is None and "properties" in node): + return (self._build_object(node, hint, defname), nullable) + + if typ == "array": + if "items" not in node or not isinstance(node["items"], dict): + raise GenError("arrays require a single 'items' schema") + elem, elem_nullable = self._unpack( + self.build_type(node["items"], hint + "Item") + ) + return (TArr(elem, elem_nullable), nullable) + + scalar = { + "string": "str", + "integer": "int", + "number": "dbl", + "boolean": "bool", + }.get(typ) + if scalar: + return (TScalar(scalar), nullable) + if typ == "null": + raise GenError("'null'-only types are not supported") + + raise GenError(f"unsupported schema (type={typ!r})") + + @staticmethod + def _unpack(result): + """build_type returns (ty, nullable) for some paths and a bare ty for + recursive object registration. Normalize to (ty, nullable).""" + if isinstance(result, tuple): + return result + return (result, False) + + def _build_object(self, node, hint, defname): + name = self.unique_name(defname or hint) + obj = ObjectType(name) + self.objects[name] = obj + # register for $ref cycles before building fields + if defname is not None: + self._building[defname] = TObj(name) + ap = node.get("additionalProperties", True) + if isinstance(ap, dict): + raise GenError( + "additionalProperties with a schema (typed map) is not " "supported yet" + ) + obj.strict = ap is False + required = set(node.get("required", [])) + props = node.get("properties", {}) + seen_cpp = set() + for key, sub in props.items(): + ty, nullable = self._unpack(self.build_type(sub, key)) + cpp = sanitize(key) + base = cpp + i = 1 + while cpp in seen_cpp: + i += 1 + cpp = f"{base}{i}" + seen_cpp.add(cpp) + obj.fields.append(Field(key, cpp, ty, key in required, nullable)) + return TObj(name) + + +# --------------------------------------------------------------------------- +# Recursion breaking + struct ordering +# --------------------------------------------------------------------------- +def opt_storage(field): + """True if the field is stored in a std::optional (absent or null-valued). + A field is optional storage when it is not required or accepts null. + unique_ptr fields are already nullable, so they are excluded.""" + return (not field.required or field.nullable) and not field.unique + + +def needs_complete(field): + """True if `field`'s storage requires the referenced object to be a + complete type at the point of the owning struct's definition. + optional and value members need T complete; unique_ptr does not.""" + return isinstance(field.ty, TObj) and not field.unique + + +def break_cycles(objects): + """Mark fields as unique_ptr to break definition cycles. An edge O->T + exists when O stores T by value/optional (needs T complete).""" + while True: + color = {} # name -> 0 white,1 gray,2 black + cycle_field = [None] + + def dfs(name): + color[name] = 1 + for f in objects[name].fields: + if not needs_complete(f): + continue + t = f.ty.name + c = color.get(t, 0) + if c == 1: # back edge -> cycle + cycle_field[0] = f + return True + if c == 0 and dfs(t): + # break at the deepest unbroken edge we control + if cycle_field[0] is None: + cycle_field[0] = f + return True + color[name] = 2 + return False + + found = False + for name in objects: + if color.get(name, 0) == 0 and dfs(name): + found = True + break + if not found: + return + cycle_field[0].unique = True + + +def order_objects(objects): + """Topologically order structs so value/optional members are defined + after their dependencies.""" + ordered = [] + state = {} # 0 unvisited, 1 visiting, 2 done + + def visit(name): + s = state.get(name, 0) + if s == 2: + return + if s == 1: + return # remaining cycles are via unique_ptr/vector; safe + state[name] = 1 + for f in objects[name].fields: + if needs_complete(f): + visit(f.ty.name) + state[name] = 2 + ordered.append(name) + + for name in objects: + visit(name) + return ordered + + +# --------------------------------------------------------------------------- +# C++ rendering +# --------------------------------------------------------------------------- +class Emitter: + def __init__(self, b, namespace): + self.b = b + self.namespace = namespace + self.arr_kinds = {} # vector-cpp-signature -> kind name + self.arr_types = [] # list[(kind_name, TArr)] + self.kind_order = [] # all Kind enumerators in declaration order + self.root_ty = None + self.root_nullable = False + + # -- type strings ------------------------------------------------------- + def base_cpp(self, ty): + if isinstance(ty, TScalar): + return { + "str": "std::string", + "int": "int64_t", + "dbl": "double", + "bool": "bool", + }[ty.kind] + if isinstance(ty, TEnum): + return ty.name + if isinstance(ty, TObj): + return ty.name + if isinstance(ty, TArr): + return f"std::vector<{self.storage_cpp(ty.elem, ty.elem_nullable, False)}>" + raise GenError("bad type") + + def storage_cpp(self, ty, nullable, unique): + base = self.base_cpp(ty) + if unique: + return f"std::unique_ptr<{base}>" + if nullable: + return f"std::optional<{base}>" + return base + + # -- Kind registration -------------------------------------------------- + def arr_kind(self, tarr): + sig = self.base_cpp(tarr) + if sig not in self.arr_kinds: + name = f"Arr{len(self.arr_kinds)}" + self.arr_kinds[sig] = name + self.arr_types.append((name, tarr)) + return self.arr_kinds[sig] + + def cat(self, ty): + if isinstance(ty, TScalar): + return {"str": "Str", "int": "Int", "dbl": "Dbl", "bool": "Bool"}[ty.kind] + if isinstance(ty, TEnum): + return "Enum" + if isinstance(ty, TObj): + return "Obj" + if isinstance(ty, TArr): + return "Arr" + raise GenError("bad type") + + def child_kind(self, ty): + if isinstance(ty, TObj): + return f"Kind::{ty.name}" + if isinstance(ty, TArr): + return f"Kind::{self.arr_kind(ty)}" + return "Kind{}" + + # -- top-level emit ----------------------------------------------------- + def emit(self): + self.root_ty, self.root_nullable = Builder._unpack( + self.b.build_type(self.b.root_schema, "Root") + ) + break_cycles(self.b.objects) + + # register all array kinds (walk every field + root) + self._walk_arrays(self.root_ty) + for obj in self.b.objects.values(): + for f in obj.fields: + self._walk_arrays(f.ty) + + order = order_objects(self.b.objects) + + parts = [] + parts.append(self._header()) + parts.append(self._enums()) + parts.append(self._struct_decls(order)) + parts.append(self._root_alias()) + parts.append(self._builder(order)) + parts.append(self._footer()) + return "\n".join(p for p in parts if p) + + def _walk_arrays(self, ty): + if isinstance(ty, TArr): + self.arr_kind(ty) + self._walk_arrays(ty.elem) + + # -- sections ----------------------------------------------------------- + def _header(self): + ns = self.namespace + return f"""// Generated by weaseljson_schemagen.py -- do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "weaseljson.h" + +namespace {ns} {{""" + + def _footer(self): + return f"}} // namespace {self.namespace}\n" + + def _enums(self): + if not self.b.enums: + return "" + out = [] + for e in self.b.enums.values(): + vals = ", ".join(sanitize(v) for v in e.values) + out.append(f"enum class {e.name} : int {{ {vals} }};") + return "\n".join(out) + "\n" + + def _struct_decls(self, order): + fwd = "\n".join(f"struct {n};" for n in self.b.objects) + out = [fwd, ""] + for name in order: + obj = self.b.objects[name] + out.append(f"struct {name} {{") + for f in obj.fields: + store = self.storage_cpp(f.ty, opt_storage(f), f.unique) + init = "" + if ( + isinstance(f.ty, TScalar) + and not opt_storage(f) + and not f.unique + and f.ty.kind in ("int", "dbl", "bool") + ): + init = " = 0" if f.ty.kind != "bool" else " = false" + out.append(f' {store} {f.cpp}{init}; // "{f.key}"') + out.append("};") + out.append("") + return "\n".join(out) + + def _root_alias(self): + if isinstance(self.root_ty, TObj) and not self.root_nullable: + if self.root_ty.name == "Root": + return "" # the struct is already named Root + return f"using Root = {self.root_ty.name};\n" + store = self.storage_cpp(self.root_ty, self.root_nullable, False) + return f"using Root = {store};\n" + + # -- the builder -------------------------------------------------------- + def _kind_enum(self): + kinds = list(self.b.objects.keys()) + kinds += [n for n, _ in self.arr_types] + root_is_container = ( + isinstance(self.root_ty, (TObj, TArr)) and not self.root_nullable + ) + if not root_is_container: + kinds.append("RootScalar") + kinds.append("Skip") + self.kind_order = kinds + return " enum class Kind : uint8_t { " + ", ".join(kinds) + " };" + + def _root_info(self): + """Return (root_cat, root_container_kind_or_None).""" + if isinstance(self.root_ty, TObj) and not self.root_nullable: + return ("Obj", f"Kind::{self.root_ty.name}") + if isinstance(self.root_ty, TArr) and not self.root_nullable: + return ("Arr", f"Kind::{self.arr_kind(self.root_ty)}") + return (self.cat(self.root_ty), None) + + def _slotinfo(self): + lines = [" SlotInfo slotInfo(const Frame &f) const {", " switch (f.kind) {"] + for name, obj in self.b.objects.items(): + lines.append(f" case Kind::{name}:") + lines.append(" switch (f.field) {") + for i, fld in enumerate(obj.fields): + lines.append( + f" case {i}: return {self._slot_expr(fld.ty, fld.nullable)};" + ) + lines.append(" default: return SlotInfo{Cat::Reject};") + lines.append(" }") + for name, tarr in self.arr_types: + lines.append( + f" case Kind::{name}: return {self._slot_expr(tarr.elem, tarr.elem_nullable)};" + ) + root_cat, root_kind = self._root_info() + if root_kind is None: + lines.append( + f" case Kind::RootScalar: return {self._slot_expr(self.root_ty, self.root_nullable)};" + ) + lines.append(" default: return SlotInfo{Cat::Reject};") + lines.append(" }") + lines.append(" }") + return "\n".join(lines) + + def _slot_expr(self, ty, nullable): + cat = self.cat(ty) + nb = "true" if nullable else "false" + if isinstance(ty, TEnum): + return ( + f"SlotInfo{{Cat::Enum, Kind{{}}, {nb}, " + f"{ty.name}_names, {len(self.b.enums[ty.name].values)}}}" + ) + if isinstance(ty, (TObj, TArr)): + return f"SlotInfo{{Cat::{cat}, {self.child_kind(ty)}, {nb}}}" + return f"SlotInfo{{Cat::{cat}, Kind{{}}, {nb}}}" + + def _engage(self): + lines = [ + " void *engage(Frame &f, bool fresh) {", + " (void)fresh;", + " switch (f.kind) {", + ] + for name, obj in self.b.objects.items(): + lines.append(f" case Kind::{name}: {{") + lines.append(f" auto *o = ({name} *)f.dest;") + lines.append(" switch (f.field) {") + for i, fld in enumerate(obj.fields): + lines.append(f" case {i}: {self._engage_field(fld)}") + lines.append(" default: return nullptr;") + lines.append(" }") + lines.append(" }") + for name, tarr in self.arr_types: + vectype = self.base_cpp(tarr) + lines.append(f" case Kind::{name}: {{") + lines.append(f" auto *v = ({vectype} *)f.dest;") + lines.append(" if (fresh) v->emplace_back();") + if tarr.elem_nullable: + lines.append(" auto &e = v->back();") + lines.append(" if (!e) e.emplace();") + lines.append(" return &*e;") + else: + lines.append(" return &v->back();") + lines.append(" }") + root_cat, root_kind = self._root_info() + if root_kind is None: + lines.append(" case Kind::RootScalar: return &result_;") + lines.append(" default: return nullptr;") + lines.append(" }") + lines.append(" }") + return "\n".join(lines) + + def _engage_field(self, fld): + acc = f"o->{fld.cpp}" + if fld.unique: + base = self.base_cpp(fld.ty) + return ( + f"{{ if (!{acc}) {acc} = std::make_unique<{base}>(); " + f"return {acc}.get(); }}" + ) + if opt_storage(fld): + return f"{{ if (!{acc}) {acc}.emplace(); return &*{acc}; }}" + return f"return &{acc};" + + def _matchkey(self): + lines = [ + " int matchKey(Kind k, std::string_view key) const {", + " switch (k) {", + ] + for name, obj in self.b.objects.items(): + lines.append(f" case Kind::{name}:") + for i, fld in enumerate(obj.fields): + esc = fld.key.replace("\\", "\\\\").replace('"', '\\"') + lines.append(f' if (key == "{esc}") return {i};') + lines.append(" return -1;") + lines.append(" default: return -1;") + lines.append(" }") + lines.append(" }") + return "\n".join(lines) + + def _bitmask(self, obj, pred): + mask = 0 + for i, fld in enumerate(obj.fields): + if pred(fld): + mask |= 1 << i + return mask + + def _reqmask(self): + lines = [" uint32_t requiredMask(Kind k) const {", " switch (k) {"] + for name, obj in self.b.objects.items(): + m = self._bitmask(obj, lambda f: f.required) + lines.append(f" case Kind::{name}: return {hex(m)}u;") + lines.append(" default: return 0u;") + lines.append(" }") + lines.append(" }") + return "\n".join(lines) + + def _is_object_kind(self): + objs = list(self.b.objects.keys()) + if not objs: + return " bool isObjectKind(Kind) const { return false; }" + cases = " ".join(f"case Kind::{n}:" for n in objs) + return ( + " bool isObjectKind(Kind k) const {\n" + f" switch (k) {{ {cases} return true; default: return false; }}\n" + " }" + ) + + def _is_strict(self): + strict = [n for n, o in self.b.objects.items() if o.strict] + if not strict: + return " bool isStrict(Kind) const { return false; }" + cases = " ".join(f"case Kind::{n}:" for n in strict) + return ( + " bool isStrict(Kind k) const {\n" + f" switch (k) {{ {cases} return true; default: return false; }}\n" + " }" + ) + + def _enum_name_arrays(self): + out = [] + for e in self.b.enums.values(): + lits = ", ".join( + '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"' + for v in e.values + ) + out.append( + f" static constexpr const char *{e.name}_names[] = {{ {lits} }};" + ) + return "\n".join(out) + + def _ctor_body(self): + root_cat, root_kind = self._root_info() + lines = [] + if root_kind is None: + lines.append(" stack_.push_back(Frame{Kind::RootScalar, &result_, 0});") + return "\n".join(lines) + + def _begin_container(self, event_cat, kind_expr): + """Shared body for cbBeginObject / cbBeginArray.""" + root_cat, root_kind = self._root_info() + lines = [" if (error_) return;"] + if root_kind is not None and root_cat == event_cat: + lines.append(" if (stack_.empty()) {") + lines.append(f" stack_.push_back(Frame{{{root_kind}, &result_}});") + lines.append(" return;") + lines.append(" }") + else: + lines.append(" if (stack_.empty()) { reject(); return; }") + lines.append(" Frame &f = stack_.back();") + lines.append( + " if (f.kind == Kind::Skip) { stack_.push_back(Frame{Kind::Skip, nullptr}); return; }" + ) + lines.append(" SlotInfo si = slotInfoG(f);") + lines.append( + " if (si.cat == Cat::Skip) { stack_.push_back(Frame{Kind::Skip, nullptr}); return; }" + ) + lines.append(f" if (si.cat != Cat::{event_cat}) {{ reject(); return; }}") + lines.append(" void *p = engage(f, true);") + lines.append(" stack_.push_back(Frame{si.child, p});") + return "\n".join(lines) + + def _builder(self, order): + kind_enum = self._kind_enum() + root_cat, root_kind = self._root_info() + begin_obj = self._begin_container("Obj", root_kind) + begin_arr = self._begin_container("Arr", root_kind) + + return f""" +class RootBuilder {{ +public: + explicit RootBuilder(int stackSize = 1024) {{ + cb_ = makeCallbacks(); + parser_ = WeaselJsonParser_create(stackSize, &cb_, this, 0); +{self._ctor_body()} + }} + ~RootBuilder() {{ if (parser_) WeaselJsonParser_destroy(parser_); }} + RootBuilder(const RootBuilder &) = delete; + RootBuilder &operator=(const RootBuilder &) = delete; + RootBuilder(RootBuilder &&) = delete; // frames hold interior pointers + + // Feed more bytes. `buf` may be modified in place (unescaping). Returns: + // WeaselJson_AGAIN - need more input + // WeaselJson_OK - complete and schema-valid; call take() + // WeaselJson_REJECT - invalid JSON or schema violation + // WeaselJson_OVERFLOW - nesting exceeded stackSize + WeaselJsonStatus feed(char *buf, int len) {{ + if (error_) return WeaselJson_REJECT; + WeaselJsonStatus s = WeaselJsonParser_parse(parser_, buf, len); + if (error_) return WeaselJson_REJECT; + return s; + }} + WeaselJsonStatus finish() {{ return feed(nullptr, 0); }} + + bool ok() const {{ return done_ && !error_; }} + // Valid only after feed()/finish() returned WeaselJson_OK. + Root take() {{ return std::move(result_); }} + +private: +{kind_enum} + enum class Cat {{ Reject, Str, Int, Dbl, Bool, Enum, Obj, Arr, Skip }}; + struct SlotInfo {{ + Cat cat; + Kind child{{}}; + bool nullable = false; + const char *const *enames = nullptr; + int encount = 0; + }}; + struct Frame {{ + Kind kind; + void *dest; + int field = -1; // object: selected field (-1 want key, -2 skip) + uint32_t seen = 0; // bitmask of populated fields + }}; + static constexpr int kWantKey = -1; + static constexpr int kSkip = -2; + +{self._enum_name_arrays()} + + Root result_{{}}; + std::vector stack_; + std::string scratch_; // accumulates number text / pending key / enum text + bool started_ = false; // a chunked scalar value is in progress + bool error_ = false; + bool done_ = false; + WeaselJsonParser *parser_ = nullptr; + WeaselJsonCallbacks cb_{{}}; + + void reject() {{ error_ = true; }} + + // Wrap the generated slotInfo() with the generic key/skip states. + SlotInfo slotInfoG(const Frame &f) {{ + if (isObjectKind(f.kind)) {{ + if (f.field == kSkip) return SlotInfo{{Cat::Skip}}; + if (f.field < 0) return SlotInfo{{Cat::Reject}}; + }} + return slotInfo(f); + }} + + // A value finished; update the owning container (now on top of stack). + void valueComplete() {{ + if (stack_.empty()) {{ done_ = true; return; }} + Frame &p = stack_.back(); + started_ = false; + scratch_.clear(); +{(" if (p.kind == Kind::RootScalar) { stack_.pop_back(); done_ = true; return; }" if root_kind is None else "")} + if (isObjectKind(p.kind)) {{ + if (p.field >= 0) p.seen |= (1u << p.field); + p.field = kWantKey; + }} + }} + + void cbBeginObject() {{ +{begin_obj} + }} + void cbEndObject() {{ + if (error_) return; + Frame f = stack_.back(); + if (f.kind == Kind::Skip) {{ + stack_.pop_back(); + if (stack_.empty() || stack_.back().kind != Kind::Skip) valueComplete(); + return; + }} + if (isObjectKind(f.kind) && + (f.seen & requiredMask(f.kind)) != requiredMask(f.kind)) {{ + reject(); + return; + }} + stack_.pop_back(); + valueComplete(); + }} + void cbBeginArray() {{ +{begin_arr} + }} + void cbEndArray() {{ + if (error_) return; + Frame f = stack_.back(); + if (f.kind == Kind::Skip) {{ + stack_.pop_back(); + if (stack_.empty() || stack_.back().kind != Kind::Skip) valueComplete(); + return; + }} + stack_.pop_back(); + valueComplete(); + }} + + void cbKeyData(const char *buf, int len, int done) {{ + if (error_) return; + Frame &f = stack_.back(); + if (!isObjectKind(f.kind)) {{ reject(); return; }} + scratch_.append(buf, len); + if (!done) return; + int idx = matchKey(f.kind, scratch_); + scratch_.clear(); + if (idx < 0) {{ + if (isStrict(f.kind)) {{ reject(); return; }} + f.field = kSkip; + return; + }} + if (f.seen & (1u << idx)) {{ reject(); return; }} // duplicate key + f.field = idx; + }} + + void cbStringData(const char *buf, int len, int done) {{ + if (error_) return; + Frame &f = stack_.back(); + if (f.kind == Kind::Skip) return; + SlotInfo si = slotInfoG(f); + if (si.cat == Cat::Skip) {{ if (done) valueComplete(); return; }} + if (si.cat == Cat::Str) {{ + auto *s = (std::string *)engage(f, !started_); + s->append(buf, len); + started_ = true; + if (done) valueComplete(); + return; + }} + if (si.cat == Cat::Enum) {{ + scratch_.append(buf, len); + started_ = true; + if (!done) return; + int idx = -1; + for (int i = 0; i < si.encount; ++i) + if (scratch_ == si.enames[i]) {{ idx = i; break; }} + if (idx < 0) {{ reject(); return; }} + *(int *)engage(f, true) = idx; + valueComplete(); + return; + }} + reject(); + }} + + void cbNumberData(const char *buf, int len, int done) {{ + if (error_) return; + Frame &f = stack_.back(); + if (f.kind == Kind::Skip) return; + SlotInfo si = slotInfoG(f); + if (si.cat == Cat::Skip) {{ if (done) valueComplete(); return; }} + if (si.cat != Cat::Int && si.cat != Cat::Dbl) {{ reject(); return; }} + scratch_.append(buf, len); + started_ = true; + if (!done) return; + const char *b = scratch_.data(); + const char *e = b + scratch_.size(); + void *p = engage(f, true); + if (si.cat == Cat::Int) {{ + int64_t v = 0; + auto r = std::from_chars(b, e, v); + if (r.ec != std::errc() || r.ptr != e) {{ reject(); return; }} + *(int64_t *)p = v; + }} else {{ + double v = 0; + auto r = std::from_chars(b, e, v); + if (r.ec != std::errc() || r.ptr != e) {{ reject(); return; }} + *(double *)p = v; + }} + valueComplete(); + }} + + void cbBool(bool value) {{ + if (error_) return; + Frame &f = stack_.back(); + if (f.kind == Kind::Skip) return; + SlotInfo si = slotInfoG(f); + if (si.cat == Cat::Skip) {{ valueComplete(); return; }} + if (si.cat != Cat::Bool) {{ reject(); return; }} + *(bool *)engage(f, true) = value; + valueComplete(); + }} + + void cbNull() {{ + if (error_) return; + Frame &f = stack_.back(); + if (f.kind == Kind::Skip) return; + SlotInfo si = slotInfoG(f); + if (si.cat == Cat::Skip) {{ valueComplete(); return; }} + if (!si.nullable) {{ reject(); return; }} + valueComplete(); // leave optional empty / pointer null + }} + + static WeaselJsonCallbacks makeCallbacks() {{ + WeaselJsonCallbacks c; + c.on_begin_object = [](void *u) {{ ((RootBuilder *)u)->cbBeginObject(); }}; + c.on_end_object = [](void *u) {{ ((RootBuilder *)u)->cbEndObject(); }}; + c.on_begin_array = [](void *u) {{ ((RootBuilder *)u)->cbBeginArray(); }}; + c.on_end_array = [](void *u) {{ ((RootBuilder *)u)->cbEndArray(); }}; + c.on_key_data = [](void *u, const char *b, int n, int d) {{ + ((RootBuilder *)u)->cbKeyData(b, n, d); + }}; + c.on_string_data = [](void *u, const char *b, int n, int d) {{ + ((RootBuilder *)u)->cbStringData(b, n, d); + }}; + c.on_number_data = [](void *u, const char *b, int n, int d) {{ + ((RootBuilder *)u)->cbNumberData(b, n, d); + }}; + c.on_true_literal = [](void *u) {{ ((RootBuilder *)u)->cbBool(true); }}; + c.on_false_literal = [](void *u) {{ ((RootBuilder *)u)->cbBool(false); }}; + c.on_null_literal = [](void *u) {{ ((RootBuilder *)u)->cbNull(); }}; + return c; + }} + +{self._slotinfo()} + +{self._engage()} + +{self._matchkey()} + +{self._reqmask()} + +{self._is_object_kind()} + +{self._is_strict()} +}}; +""" + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("schema") + ap.add_argument("-o", "--output") + ap.add_argument("--namespace", default="weasel_schema") + args = ap.parse_args(argv) + + with open(args.schema) as fp: + schema = json.load(fp) + + b = Builder(schema) + em = Emitter(b, args.namespace) + try: + code = em.emit() + except GenError as e: + print(f"error: {e}", file=sys.stderr) + return 1 + + if args.output: + with open(args.output, "w") as fp: + fp.write(code) + else: + sys.stdout.write(code) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))