Keep optional and nullable JSON members on the direct schema parser

`JSON.parse(text) as T` parses straight into the native layout only when
every record member is a number, boolean, string, record or array, so one
optional or nullable member sent the whole document through the dynamic
tree and the checked cast. Record members typed as unions of those kinds
with null and undefined now parse directly too, provided each JSON value
kind selects at most one arm (at most one record and one array arm, no
discriminant), so the checked route's first full match is that arm.

Each union describes its slot representation: the nullable reference
payload, the inline scalar tag and bits, or a tagged box built through a
compiler-emitted box function. A missing undefined-armed member takes its
ABSENT state, exactly as the checked route's missing key does; any other
missing member, a value without a matching arm, or a duplicate key still
declines to the checked route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-17 10:55:47 -07:00
co-authored by Claude Opus 5.5
parent 8620fa533a
commit da36100ecc
7 changed files with 600 additions and 37 deletions
+117 -7
View File
@@ -1110,9 +1110,11 @@ export class LlDyn {
/** Targets the one-pass parser (scr_json_parse_schema) can build
* directly: number, boolean and string leaves inside arrays and plain
* records (no tuple, index signature, internal '%' slot or `__proto__`
* member; 1..64 fields). Everything else keeps the checked-dynamic
* member; 1..64 fields), and record members typed as unions those leaves,
* records and arrays, null and undefined (optional and nullable members;
* see jsonUnionSupported). Everything else keeps the checked-dynamic
* route alone. */
private jsonSchemaSupported(t: IrType, visiting: Set<string>): boolean {
private jsonSchemaSupported(t: IrType, visiting: Set<string>, member = false): boolean {
switch (t.kind) {
case "f64":
case "bool":
@@ -1130,14 +1132,45 @@ export class LlDyn {
(f) =>
!f.name.startsWith("%") &&
f.name !== "__proto__" &&
this.jsonSchemaSupported(f.type, visiting),
this.jsonSchemaSupported(f.type, visiting, true),
);
}
case "union":
return member && this.jsonUnionSupported(t, visiting);
default:
return false;
}
}
/** A union record member the one-pass parser writes directly: every arm
* a number, boolean, string, null, undefined, or a supported record or
* array, with at most one record and one array arm and no discriminant.
* Each JSON value kind then selects at most one arm, so the checked
* route's first full match is that arm, and a value that arm rejects
* fails every arm. */
private jsonUnionSupported(t: IrType & { kind: "union" }, visiting: Set<string>): boolean {
const def = this.host.unionsById.get(t.unionId);
if (!def || def.discriminant) return false;
let records = 0,
arrays = 0;
return def.arms.every((arm) => {
switch (arm.kind) {
case "f64":
case "bool":
case "string":
case "nullT":
case "undefinedT":
return true;
case "record":
return ++records === 1 && this.jsonSchemaSupported(arm, visiting);
case "array":
return ++arrays === 1 && this.jsonSchemaSupported(arm, visiting);
default:
return false;
}
});
}
/** The ScrJsonSchema constant for t (scr_runtime.h layout). Records list
* fields in declaration order — the order JSON.stringify writes — so the
* runtime's next-field guess usually matches without a scan. */
@@ -1155,7 +1188,7 @@ export class LlDyn {
case "bool":
case "string": {
const kind = t.kind === "f64" ? 0 : t.kind === "bool" ? 1 : 2;
body = `${S} ${kind}, ${S} 0, ptr null, ptr null, ptr null, ptr null, ptr null`;
body = `${S} ${kind}, ${S} 0, ptr null, ptr null, ptr null, ptr null, ptr null, ptr null`;
break;
}
case "array": {
@@ -1169,7 +1202,7 @@ export class LlDyn {
`}`,
``,
);
body = `${S} 4, ${S} 0, ptr null, ptr @${elem}, ptr null, ptr @${ctor}, ptr ${vAdapters(host, t).release}`;
body = `${S} 4, ${S} 0, ptr null, ptr @${elem}, ptr null, ptr @${ctor}, ptr ${vAdapters(host, t).release}, ptr null`;
break;
}
case "record": {
@@ -1194,19 +1227,96 @@ export class LlDyn {
`@${fields} = internal constant [${entries.length} x ${fieldTy}] [${entries.join(", ")}]`,
``,
);
body = `${S} 3, ${S} ${entries.length}, ptr @${fields}, ptr null, ptr @${mangleRecordNew(t.shapeId)}, ptr null, ptr ${vAdapters(host, t).release}`;
body = `${S} 3, ${S} ${entries.length}, ptr @${fields}, ptr null, ptr @${mangleRecordNew(t.shapeId)}, ptr null, ptr ${vAdapters(host, t).release}, ptr null`;
break;
}
case "union":
body = `${S} 5, ${S} 0, ptr null, ptr null, ptr null, ptr null, ptr null, ptr @${this.jsonUnionSchema(t, name)}`;
break;
default:
throw new InternalCompilerError(`llvm emitter bug: JSON schema of ${t.kind}`);
}
this.defs.push(
`@${name} = internal constant { ${S}, ${S}, ptr, ptr, ptr, ptr, ptr } { ${body} }`,
`@${name} = internal constant { ${S}, ${S}, ptr, ptr, ptr, ptr, ptr, ptr } { ${body} }`,
``,
);
return name;
}
/** The ScrJsonUnion descriptor of a union member (scr_json.h layout):
* its slot representation, the arm each JSON value kind selects, and the
* values the runtime stores for null and for a missing member. Boxed
* unions build their boxes through `<name>_box`. */
private jsonUnionSchema(t: IrType & { kind: "union" }, name: string): string {
const host = this.host;
const S = host.sizeType;
const def = host.unionsById.get(t.unionId);
if (!def) throw new InternalCompilerError(`llvm emitter bug: JSON schema of ${t.unionId}`);
const nullable = host.nullableUnions.has(t.unionId);
const inline = host.inlineUnions.record(t);
const repr = nullable ? 0 : inline ? 1 : 2;
// Indexed by SCR_JARM_*: null, number, boolean, string, object, array, absent.
const armKinds = ["nullT", "f64", "bool", "string", "record", "array", "undefinedT"];
const tags = armKinds.map((kind) => def.arms.findIndex((arm) => arm.kind === kind));
const arms = armKinds.map((_, i) =>
i >= 1 && i <= 5 && tags[i]! >= 0
? `ptr @${this.jsonSchema(def.arms[tags[i]!]!)}`
: "ptr null",
);
const nullTag = tags[0]!;
const absentTag = tags[6]!;
const nullValue = !inline && nullTag >= 0 ? host.unitInstanceRef(t.unionId, nullTag) : "null";
const absentValue = !inline && absentTag >= 0 ? host.absentInstanceRef(t.unionId) : "null";
let box = "null";
if (repr === 2) {
box = `@${name}_box`;
const lines = [`define internal ptr ${box}(i32 %tag, i64 %bits) ${FN_ATTRS} {`, `entry:`];
const cases: string[] = [];
const blocks: string[] = [];
def.arms.forEach((arm, tag) => {
if (arm.kind === "nullT" || arm.kind === "undefinedT") return;
cases.push(`i32 ${tag}, label %arm${tag}`);
blocks.push(`arm${tag}:`);
if (arm.kind === "f64") {
host.declare(`declare ptr @scr_union_new_f64(i32, double)`);
blocks.push(
` %f${tag} = bitcast i64 %bits to double`,
` %u${tag} = call ptr @scr_union_new_f64(i32 ${tag}, double %f${tag})`,
);
} else if (arm.kind === "bool") {
host.declare(`declare ptr @scr_union_new_bool(i32, i1 zeroext)`);
blocks.push(
` %b${tag} = trunc i64 %bits to i1`,
` %u${tag} = call ptr @scr_union_new_bool(i32 ${tag}, i1 zeroext %b${tag})`,
);
} else {
const rc = vAdapters(host, arm);
host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`);
blocks.push(
` %p${tag} = inttoptr i64 %bits to ptr`,
` %u${tag} = call ptr @scr_union_new_ref(i32 ${tag}, ptr %p${tag}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host, arm)})`,
);
}
blocks.push(` ret ptr %u${tag}`);
});
this.defs.push(
...lines,
` switch i32 %tag, label %none [ ${cases.join(" ")} ]`,
`none:`,
` unreachable`,
...blocks,
`}`,
``,
);
}
const descriptor = `${name}_union`;
this.defs.push(
`@${descriptor} = internal constant { ${S}, [7 x ${S}], [7 x ptr], ptr, ptr, ptr } { ${S} ${repr}, [7 x ${S}] [${tags.map((tag) => `${S} ${tag}`).join(", ")}], [7 x ptr] [${arms.join(", ")}], ptr ${nullValue}, ptr ${absentValue}, ptr ${box} }`,
``,
);
return descriptor;
}
/** The declared-name tables scr_record_source_note scans: parallel
* constant arrays of field-name bytes and byte lengths in declaration
* order (the order the record serializes), interned per shape. Internal
@@ -16,12 +16,20 @@ test("JSON.parse casts to plain records and arrays fuse into the schema parser",
interface Item { sku: string; qty: number; ok: boolean }
interface Order { id: number; items: Item[]; tags: string[] }
interface Loose { name: string; note?: string }
interface Meta { source: string }
interface Mixed { id: number; meta: Meta | null; score: number | null; key: string | number }
interface Shape { kind: string; a: number }
interface Other { kind: string; b: string }
interface Either { s: Shape | Other }
const text = process.argv[2] ?? "[]";
const orders = JSON.parse(text) as Order[];
const typed: Item = JSON.parse(text);
const loose = JSON.parse(text) as Loose;
const mixed = JSON.parse(text) as Mixed[];
const pair = JSON.parse(text) as [string, number];
console.log(orders.length, typed.sku, loose.name, pair[0]);
const either = JSON.parse(text) as Either;
const holes = JSON.parse(text) as (string | null)[];
console.log(orders.length, typed.sku, loose.name, mixed.length, pair[0], either.s.kind, holes.length);
`,
);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
@@ -30,9 +38,10 @@ console.log(orders.length, typed.sku, loose.name, pair[0]);
const mod = deserializeModule(await readFile(outPath, "utf8"));
const llvm = emitLlvmModule(mod);
// Order[] and Item (an annotated binding) take the fused path; the
// record schema lists fields in declaration order with struct offsets.
expect(llvm.match(/^define internal ptr @sc_jp_\d+(?:\.i32)?\(ptr %text\)/gm)).toHaveLength(2);
// Order[], Item (an annotated binding), and the optional and nullable
// members of Loose and Mixed[] take the fused path; the record schema
// lists fields in declaration order with struct offsets.
expect(llvm.match(/^define internal ptr @sc_jp_\d+(?:\.i32)?\(ptr %text\)/gm)).toHaveLength(4);
expect(llvm).toContain("call ptr @scr_json_parse_schema(ptr %text, ptr @sc_jsch_");
expect(llvm).toMatch(
/@sc_jsch_\d+_fields = internal constant \[3 x \{ ptr, i64, i64, ptr \}\] \[\{ ptr, i64, i64, ptr \} \{ ptr @sc_cs_\d+, i64 3, i64 ptrtoint/,
@@ -42,11 +51,26 @@ console.log(orders.length, typed.sku, loose.name, pair[0]);
/%d = call ptr @scr_json_parse\(ptr %text\)\n {2}%bad = icmp eq ptr %d, null/,
);
expect(llvm).toMatch(/%v = call ptr @sc_dc_\d+\(ptr %d, ptr null\)/);
// Optional fields (unions) and tuples keep the checked-dynamic route.
expect(llvm.match(/call ptr @scr_json_parse\(ptr /g)!.length).toBeGreaterThanOrEqual(4);
// Union members describe their slot representation: the nullable
// record pointer (0), the inline scalar pair (1), and a tagged box built
// through the schema's box function (2), with arm tags by JSON kind.
expect(llvm).toMatch(
/@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr \} \{ i64 0, /,
);
expect(llvm).toMatch(
/@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr \} \{ i64 1, /,
);
expect(llvm).toMatch(
/@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr \} \{ i64 2, .*ptr @sc_jsch_\d+_box \}/,
);
expect(llvm).toMatch(/^define internal ptr @sc_jsch_\d+_box\(i32 %tag, i64 %bits\)/m);
// Tuples, two record arms, and unions outside a record member keep the
// checked-dynamic route alone.
expect(llvm.match(/call ptr @scr_json_parse\(ptr /g)!.length).toBeGreaterThanOrEqual(7);
const wasm = emitLlvmModule(mod, { pointerBits: 32, wasi: true });
expect(wasm).toContain("= internal constant { i32, i32, ptr, ptr, ptr, ptr, ptr }");
expect(wasm).toContain("= internal constant { i32, i32, ptr, ptr, ptr, ptr, ptr, ptr }");
expect(wasm).toContain("= internal constant { i32, [7 x i32], [7 x ptr], ptr, ptr, ptr }");
expect(wasm).toMatch(/\{ ptr, i32, i32, ptr \} \{ ptr @sc_cs_\d+, i32 3, i32 ptrtoint/);
} finally {
await rm(dir, { recursive: true, force: true });
+94 -19
View File
@@ -7998,7 +7998,10 @@ ScrDyn *scr_json_parse(ScrStr *text) {
* layout the compiler describes in a ScrJsonSchema. It accepts only input
* for which the checked-dynamic route (scr_json_parse + the generated
* dynCheck builder) would succeed with the same value: every declared key
* present exactly once with a value of its declared kind; undeclared keys,
* present exactly once with a value of its declared kind, except that a
* missing undefined-armed union member takes its ABSENT state (the checked
* route's missing key reads undefined: parsed objects have no prototype
* chain to inherit from); undeclared keys,
* or declared keys out of declaration order, are kept beside the record in
* source order (scr_record_source_note's contract). Anything else — a
* syntax error, a kind mismatch, a missing or repeated key, excessive
@@ -8123,6 +8126,92 @@ static inline __attribute__((always_inline)) bool scr_jschema_member(ScrJsonP *p
return scr_jschema_scalar(p, type->kind, out);
}
/* An inline union field slot (INLINE_UNION_FIELD in the backend). */
typedef struct {
uint32_t tag;
uint64_t bits;
} ScrJsonInlineUnion;
static void scr_jschema_inline_store(char *slot, size_t tag, uint64_t bits) {
ScrJsonInlineUnion pair = { (uint32_t)tag, bits };
memcpy(slot, &pair, sizeof pair);
}
/* A union member value written into its field slot. Each JSON value kind
* selects at most one arm (the compiler admits no two arms one kind could
* both match), so the checked route's first full match is that arm, and
* any other value declines. */
static bool scr_jschema_union(ScrJsonP *p, const ScrJsonUnion *u, char *slot) {
scr_json_ws(p);
if (p->pos >= p->len) return false;
char c = p->s[p->pos];
size_t arm;
if (c == 'n') arm = SCR_JARM_NULL;
else if (c == '"') arm = SCR_JARM_STR;
else if (c == '{') arm = SCR_JARM_OBJ;
else if (c == '[') arm = SCR_JARM_ARR;
else if (c == 't' || c == 'f') arm = SCR_JARM_BOOL;
else if (c == '-' || (c >= '0' && c <= '9')) arm = SCR_JARM_NUM;
else return false;
size_t tag = u->tags[arm];
if (tag == SCR_JSCHEMA_NO_ARM) return false;
if (arm == SCR_JARM_NULL) {
if (!scr_json_lit(p, "null", 4)) return false;
if (u->repr == SCR_JUNION_INLINE) scr_jschema_inline_store(slot, tag, 0);
else memcpy(slot, &u->null_value, sizeof u->null_value); /* immortal */
return true;
}
ScrJsonSlot v;
if (!scr_jschema_read(p, u->arms[arm], &v)) return false;
uint64_t bits = 0;
if (arm == SCR_JARM_NUM) memcpy(&bits, &v.f, sizeof bits);
else if (arm == SCR_JARM_BOOL) bits = v.b ? 1 : 0;
else bits = (uint64_t)(uintptr_t)v.ptr;
switch (u->repr) {
case SCR_JUNION_NULLABLE: memcpy(slot, &v.ptr, sizeof v.ptr); break; /* moves in */
case SCR_JUNION_INLINE: scr_jschema_inline_store(slot, tag, bits); break;
default: {
void *box = u->box((uint32_t)tag, bits); /* the payload moves in */
memcpy(slot, &box, sizeof box);
}
}
return true;
}
/* A declared member value written into its (zeroed) slot of `rec`. */
static inline __attribute__((always_inline)) bool scr_jschema_field(ScrJsonP *p,
const ScrJsonSchemaField *f,
char *rec) {
char *slot = rec + f->offset;
if (f->type->kind == SCR_JSCHEMA_UNION) return scr_jschema_union(p, f->type->uni, slot);
ScrJsonSlot v;
if (!scr_jschema_member(p, f->type, &v)) return false;
switch (f->type->kind) {
case SCR_JSCHEMA_F64: memcpy(slot, &v.f, sizeof v.f); break;
case SCR_JSCHEMA_BOOL: *(uint8_t *)slot = v.b ? 1 : 0; break;
default: memcpy(slot, &v.ptr, sizeof v.ptr); break; /* zeroed slot; moves in */
}
return true;
}
/* The declared members a record's text omitted (`missing`, by field
* index): each undefined-armed union member takes its ABSENT state, as the
* checked route's missing key does. Any other omitted member declines. */
static bool scr_jschema_absent(const ScrJsonSchema *s, char *rec, uint64_t missing) {
while (missing) {
const ScrJsonSchemaField *f = &s->fields[__builtin_ctzll(missing)];
missing &= missing - 1;
if (f->type->kind != SCR_JSCHEMA_UNION) return false;
const ScrJsonUnion *u = f->type->uni;
size_t tag = u->tags[SCR_JARM_ABSENT];
if (tag == SCR_JSCHEMA_NO_ARM) return false;
char *slot = rec + f->offset;
if (u->repr == SCR_JUNION_INLINE) scr_jschema_inline_store(slot, tag, 1); /* ABSENT_FIELD_PAYLOAD */
else memcpy(slot, &u->absent_value, sizeof u->absent_value); /* immortal */
}
return true;
}
/* The quoted key at p->pos when it is exactly `f`'s name, written without
* escapes: the bytes consumed (name length + 2), else 0. Names of more
* than eight bytes, and names holding a byte JSON text would escape,
@@ -8179,14 +8268,7 @@ static __attribute__((noinline, cold)) bool scr_jschema_record_kept(
if (seen & bit) goto fail; /* duplicate: last-wins stays on the checked route */
seen |= bit;
guess = idx + 1;
ScrJsonSlot v;
if (!scr_jschema_member(p, f->type, &v)) goto fail;
char *slot = rec + f->offset;
switch (f->type->kind) {
case SCR_JSCHEMA_F64: memcpy(slot, &v.f, sizeof v.f); break;
case SCR_JSCHEMA_BOOL: *(uint8_t *)slot = v.b ? 1 : 0; break;
default: memcpy(slot, &v.ptr, sizeof v.ptr); break; /* zeroed slot; moves in */
}
if (!scr_jschema_field(p, f, rec)) goto fail;
scr_dyn_obj_set(view, key, key_len, scr_dyn_retain(scr_dyn_undefined()));
} else {
/* A repeated undeclared key stays on the checked route too. */
@@ -8230,7 +8312,7 @@ static __attribute__((noinline, cold)) bool scr_jschema_record_kept(
}
}
}
if (seen != all) goto fail;
if (seen != all && !scr_jschema_absent(s, rec, all & ~seen)) goto fail;
ScrRecordSource entry = { rec, view, (const char *)&s->fields[0].name,
(const char *)&s->fields[0].name_len, sizeof *s->fields,
sizeof *s->fields, n };
@@ -8299,14 +8381,7 @@ static bool scr_jschema_record(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot
if (seen & bit) goto fail; /* duplicate: last-wins stays on the checked route */
seen |= bit;
guess = idx + 1;
ScrJsonSlot v;
if (!scr_jschema_member(p, f->type, &v)) goto fail;
char *slot = rec + f->offset;
switch (f->type->kind) {
case SCR_JSCHEMA_F64: memcpy(slot, &v.f, sizeof v.f); break;
case SCR_JSCHEMA_BOOL: *(uint8_t *)slot = v.b ? 1 : 0; break;
default: memcpy(slot, &v.ptr, sizeof v.ptr); break; /* zeroed slot; moves in */
}
if (!scr_jschema_field(p, f, rec)) goto fail;
}
scr_json_ws(p);
if (p->pos >= p->len) goto fail;
@@ -8319,7 +8394,7 @@ static bool scr_jschema_record(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot
break;
}
close:
if (seen != all) goto fail;
if (seen != all && !scr_jschema_absent(s, rec, all & ~seen)) goto fail;
out->ptr = rec;
return true;
fail:
+40 -4
View File
@@ -12,16 +12,41 @@ ScrDyn *scr_json_parse(ScrStr *text);
/* Compiler-emitted description of a statically known JSON.parse target
* (`JSON.parse(text) as T`). Constant data in the program image; the LLVM
* backend mirrors this layout ({size, size, ptr, ptr, ptr, ptr, ptr} and
* {ptr, size, size, ptr}). Records list their declared fields (at most
* 64), each with its byte offset in the record struct; bool fields are one
* byte, f64 fields doubles, every other kind a +1 pointer. */
* backend mirrors this layout ({size, size, ptr, ptr, ptr, ptr, ptr, ptr},
* {ptr, size, size, ptr} and {size, [7 x size], [7 x ptr], ptr, ptr, ptr}).
* Records list their declared fields (at most 64), each with its byte
* offset in the record struct; bool fields are one byte, f64 fields
* doubles, inline union fields a {uint32 tag, uint64 bits} pair, every
* other kind a +1 pointer. Unions appear only as record field types. */
enum {
SCR_JSCHEMA_F64 = 0,
SCR_JSCHEMA_BOOL = 1,
SCR_JSCHEMA_STR = 2,
SCR_JSCHEMA_REC = 3,
SCR_JSCHEMA_ARR = 4,
SCR_JSCHEMA_UNION = 5,
};
/* A union's arms by the JSON value kind that selects them. ABSENT names
* the undefined arm, which no JSON value selects: a missing member takes
* the field's ABSENT state through it. */
enum {
SCR_JARM_NULL = 0,
SCR_JARM_NUM = 1,
SCR_JARM_BOOL = 2,
SCR_JARM_STR = 3,
SCR_JARM_OBJ = 4,
SCR_JARM_ARR = 5,
SCR_JARM_ABSENT = 6,
SCR_JARM_COUNT = 7,
};
#define SCR_JSCHEMA_NO_ARM SIZE_MAX
/* How a union field slot stores its value (the compiler's choice for the
* union type): the reference payload itself (nullable unions), the tag
* and bits in the record (scalar unions), or a tagged box. */
enum {
SCR_JUNION_NULLABLE = 0,
SCR_JUNION_INLINE = 1,
SCR_JUNION_BOXED = 2,
};
typedef struct ScrJsonSchema ScrJsonSchema;
typedef struct {
@@ -30,6 +55,16 @@ typedef struct {
size_t offset;
const ScrJsonSchema *type;
} ScrJsonSchemaField;
typedef struct {
size_t repr;
size_t tags[SCR_JARM_COUNT]; /* canonical arm tag, or SCR_JSCHEMA_NO_ARM */
const ScrJsonSchema *arms[SCR_JARM_COUNT]; /* value arms (NUM..ARR) */
void *null_value; /* nullable, boxed: the null arm's value */
void *absent_value; /* nullable, boxed: the ABSENT field state */
/* boxed: a +1 box of the value arm `tag`; bits hold its double, its
* bool, or its +1 payload pointer (moved in). */
void *(*box)(uint32_t tag, uint64_t bits);
} ScrJsonUnion;
struct ScrJsonSchema {
size_t kind;
size_t nfields;
@@ -38,6 +73,7 @@ struct ScrJsonSchema {
void *(*rec_new)(void); /* records: zeroed instance, rc 1 */
ScrArr *(*arr_new)(size_t); /* arrays: empty array of the element kind */
void (*release)(void *); /* records and arrays */
const ScrJsonUnion *uni; /* unions */
};
/* One-pass parse straight into the native layout. Returns the +1 record
+112
View File
@@ -691,6 +691,117 @@ static void json_schema_parse(void) {
#endif
}
/* Union members: a nullable string with an undefined arm, an inline
* number | null | undefined pair, and a boxed number | string. */
typedef struct {
uint32_t tag;
uint64_t bits;
} TestInline;
typedef struct {
size_t rc;
ScrStr *name; /* string | undefined: the payload, NULL, or test_absent */
TestInline age; /* number (0) | null (1) | undefined (2) */
ScrUnion *key; /* number (0) | string (1) */
} TestOpt;
static size_t test_absent[4] = {SIZE_MAX, 0, 0, 0};
static int test_opts_live = 0;
static void *test_opt_new(void) {
TestOpt *opt = calloc(1, sizeof *opt);
assert(opt);
opt->rc = 1;
test_opts_live++;
return opt;
}
static void test_opt_release(void *ptr) {
TestOpt *opt = ptr;
if (!opt || --opt->rc) return;
if (opt->name && opt->name != (ScrStr *)test_absent) scr_str_release(opt->name);
scr_union_release(opt->key);
test_opts_live--;
scr_weak_dispose(opt);
free(opt);
}
static void *test_key_box(uint32_t tag, uint64_t bits) {
if (tag == 0) {
double d;
memcpy(&d, &bits, sizeof d);
return scr_union_new_f64(0, d);
}
return scr_union_new_ref(1, (void *)(uintptr_t)bits, scr_str_retain_v, scr_str_release_v, NULL);
}
#define NO_ARM SCR_JSCHEMA_NO_ARM
static const ScrJsonUnion test_name_union = {
SCR_JUNION_NULLABLE, {NO_ARM, NO_ARM, NO_ARM, 0, NO_ARM, NO_ARM, 1},
{NULL, NULL, NULL, &test_str, NULL, NULL, NULL}, NULL, test_absent, NULL};
static const ScrJsonUnion test_age_union = {
SCR_JUNION_INLINE, {1, 0, NO_ARM, NO_ARM, NO_ARM, NO_ARM, 2},
{NULL, &test_f64, NULL, NULL, NULL, NULL, NULL}, NULL, NULL, NULL};
static const ScrJsonUnion test_key_union = {
SCR_JUNION_BOXED, {NO_ARM, 0, NO_ARM, 1, NO_ARM, NO_ARM, NO_ARM},
{NULL, &test_f64, NULL, &test_str, NULL, NULL, NULL}, NULL, NULL, test_key_box};
#undef NO_ARM
static const ScrJsonSchema test_name = {SCR_JSCHEMA_UNION, 0, NULL, NULL, NULL, NULL, NULL, &test_name_union};
static const ScrJsonSchema test_age = {SCR_JSCHEMA_UNION, 0, NULL, NULL, NULL, NULL, NULL, &test_age_union};
static const ScrJsonSchema test_key = {SCR_JSCHEMA_UNION, 0, NULL, NULL, NULL, NULL, NULL, &test_key_union};
static const ScrJsonSchemaField test_opt_fields[] = {
{"name", 4, offsetof(TestOpt, name), &test_name},
{"age", 3, offsetof(TestOpt, age), &test_age},
{"key", 3, offsetof(TestOpt, key), &test_key},
};
static const ScrJsonSchema test_opt = {SCR_JSCHEMA_REC, 3, test_opt_fields, NULL, test_opt_new, NULL, test_opt_release};
static TestOpt *opt_parse(const char *text) {
ScrStr *input = scr_str_new(text, strlen(text));
TestOpt *opt = scr_json_parse_schema(input, &test_opt);
scr_str_release(input);
assert(!scr_exc_pending());
return opt;
}
static void json_schema_union_members(void) {
TestOpt *opt = opt_parse("{\"name\":\"n\",\"age\":2.5,\"key\":\"k\"}");
assert(opt && str_is(opt->name, "n") && opt->age.tag == 0);
double age;
memcpy(&age, &opt->age.bits, sizeof age);
assert(age == 2.5);
assert(opt->key->tag == 1 && str_is(scr_union_peek(opt->key), "k"));
assert(!scr_record_source_has(opt));
test_opt_release(opt);
/* Missing undefined-armed members take their ABSENT states; null lands
* in the null arm with zero bits. */
opt = opt_parse("{\"key\":-1}");
assert(opt && opt->name == (ScrStr *)test_absent);
assert(opt->age.tag == 2 && opt->age.bits == 1);
assert(opt->key->tag == 0 && scr_union_get_f64(opt->key) == -1);
test_opt_release(opt);
opt = opt_parse("{\"age\":null,\"key\":0}");
assert(opt && opt->name == (ScrStr *)test_absent && opt->age.tag == 1 && opt->age.bits == 0);
test_opt_release(opt);
/* A reordered member keeps the source beside the record, absent members
* included. */
opt = opt_parse("{\"key\":\"z\",\"name\":\"y\"}");
assert(opt && str_is(opt->name, "y") && opt->age.tag == 2 && opt->age.bits == 1);
assert(scr_record_source_has(opt));
test_opt_release(opt);
/* No arm of the value's kind, or a missing member without an undefined
* arm, declines. */
static const char *const declined[] = {
"{\"name\":null,\"key\":1}",
"{\"age\":\"1\",\"key\":1}",
"{\"key\":true}",
"{\"key\":null}",
"{\"name\":\"a\"}",
"{}",
"{\"key\":1,\"key\":2}",
"{\"age\":nul,\"key\":1}",
};
for (size_t i = 0; i < sizeof declined / sizeof *declined; i++) {
assert(!opt_parse(declined[i]));
assert(test_opts_live == 0);
}
assert(test_opts_live == 0);
}
int main(void) {
json_string_boundaries();
json_indent_ownership();
@@ -702,6 +813,7 @@ int main(void) {
json_intact_regions();
json_schema_parse();
json_schema_special_names();
json_schema_union_members();
/* Native Set boxes may own headerless scalar/string maps. Collecting an
* enclosing cycle must neither trace those leaves nor skip their release. */
for (int i = 0; i < 2000; i++) {
@@ -0,0 +1,137 @@
// JSON.parse(text) as T keeps records with optional and nullable members on
// the one-pass schema parser. A missing optional member is ABSENT (no own
// property), a present one holds its value, and JSON null lands in the null
// arm. Key order, `in`, Object.keys, hasOwnProperty, stringify, and
// later writes and deletes observe exactly what Node's parsed object has.
// Every input is a VALID cast, so Node (where `as` is a no-op) and the native
// binary must agree on both the direct and the checked-dynamic route.
interface Meta {
source: string;
version: number;
}
interface User {
id: number;
name?: string;
email?: string | null;
age?: number;
verified?: boolean;
metadata: Meta | null;
parent: number | null;
note: string | null;
tags?: string[];
manager?: Meta | null;
}
function describe(label: string, u: User): void {
const keys = Object.keys(u).join(",");
const has = ["name", "email", "age", "verified", "tags", "manager"]
.map((k) => `${k}:${k in u ? "y" : "n"}`)
.join(" ");
console.log(label, keys);
console.log(" has", has, Object.prototype.hasOwnProperty.call(u, "age"));
console.log(
" values",
u.id,
u.name === undefined ? "-" : u.name,
u.email === undefined ? "undef" : u.email === null ? "null" : u.email,
u.age ?? -1,
u.verified === undefined ? "?" : u.verified,
u.metadata === null ? "nometa" : `${u.metadata.source}@${u.metadata.version}`,
u.parent ?? "root",
u.note ?? "nonote",
u.tags === undefined ? "notags" : u.tags.join("+"),
u.manager === undefined ? "nomgr" : u.manager === null ? "nullmgr" : u.manager.source,
);
console.log(" json", JSON.stringify(u));
}
function attempt(label: string, text: string): void {
try {
describe(label, JSON.parse(text) as User);
} catch (e) {
console.log("caught", label, e instanceof SyntaxError);
}
}
// Every member present, in declaration order.
attempt(
"full",
'{"id":1,"name":"ada","email":"a@x.io","age":36,"verified":true,"metadata":{"source":"api","version":2},' +
'"parent":0,"note":"hi","tags":["a","b"],"manager":{"source":"hr","version":1}}',
);
// Only the required members: each optional member is absent.
attempt("required only", '{"id":2,"metadata":null,"parent":null,"note":null}');
// Absent members at the start, middle and end of the declaration order.
attempt("gaps", '{"id":3,"age":0,"metadata":null,"parent":2,"note":"","manager":null}');
attempt("tail", '{"id":4,"name":"","metadata":{"source":"","version":-0},"parent":-1.5,"note":null}');
// Explicit nulls for nullable optional members stay present.
attempt("nulls", '{"id":5,"email":null,"metadata":null,"parent":null,"note":null,"manager":null}');
// Keys out of declaration order with absent members: Node's key order wins.
attempt("reordered", '{"note":"n","parent":7,"id":6,"metadata":null,"verified":false,"name":"r"}');
// Undeclared members beside absent ones.
attempt(
"extra",
'{"id":7,"extra":{"deep":[1,null]},"metadata":null,"parent":null,"note":null,"age":44,"more":"x"}',
);
// Escaped keys still match optional members.
attempt("escaped", '{"id":8,"n\\u0061me":"esc","metadata":null,"parent":null,"note":null,"\\u0061ge":3}');
// Whitespace everywhere.
attempt(
"spaced",
' {\n "id" : 9 ,\t"tags" : [ ] , "metadata" : { "source" : "s" , "version" : 1 } ,\r\n "parent" : null , "note" : null } ',
);
// Duplicate keys: the last value wins, as in Node.
attempt("duplicate", '{"id":10,"age":1,"metadata":null,"parent":null,"note":null,"age":2}');
attempt("duplicate null", '{"id":11,"note":"a","metadata":null,"parent":null,"note":null}');
// Syntax errors throw the same SyntaxError on both sides.
attempt("truncated", '{"id":12,"metadata":null,"parent":null,"note":null');
attempt("trailing comma", '{"id":13,"metadata":null,"parent":null,"note":null,}');
// Arrays of records with optional members, mixing present and absent.
const many = JSON.parse(
'[{"id":1,"metadata":null,"parent":null,"note":null},' +
'{"id":2,"name":"b","metadata":{"source":"x","version":3},"parent":1,"note":"n","age":20},' +
'{"id":3,"verified":true,"metadata":null,"parent":2,"note":null,"tags":[]}]',
) as User[];
let total = 0;
for (const u of many) {
total += u.id + (u.age ?? 0) + (u.metadata?.version ?? 0);
console.log(u.id, Object.keys(u).length, "name" in u, "verified" in u, JSON.stringify(u));
}
console.log("total", total);
// Writes after the parse: assigning undefined keeps the key, delete drops it.
const w = JSON.parse('{"id":20,"name":"w","metadata":null,"parent":null,"note":null}') as User;
w.name = undefined;
console.log("name" in w, Object.keys(w).join(","), JSON.stringify(w));
delete w.name;
console.log("name" in w, Object.keys(w).join(","));
// Nested optional records inside optional records.
interface Node {
label: string;
child?: Node;
next: Node | null;
}
function walk(n: Node, depth: number): void {
console.log(" ".repeat(depth) + n.label, "child" in n, n.next === null);
if (n.child !== undefined) walk(n.child, depth + 1);
if (n.next !== null) walk(n.next, depth);
}
const tree = JSON.parse(
'{"label":"root","child":{"label":"a","next":{"label":"b","child":{"label":"c","next":null},"next":null}},"next":null}',
) as Node;
walk(tree, 0);
console.log(JSON.stringify(tree));
// Every member optional: the empty object parses with every member absent.
interface Loose {
a?: number;
b?: string;
c?: boolean;
}
const empty = JSON.parse("{}") as Loose;
console.log(Object.keys(empty).length, "a" in empty, JSON.stringify(empty));
const some = JSON.parse('{"c":false,"a":1}') as Loose;
console.log(Object.keys(some).join(","), some.a, some.b, some.c, JSON.stringify(some));
@@ -0,0 +1,69 @@
// JSON.parse(text) as T with union-typed record members whose JSON value
// kind selects one arm: scalar unions (stored inline in the record), a
// reference arm with null or undefined, and tagged-box unions mixing
// strings, numbers, booleans, records and arrays. Values, narrowing, key
// presence and stringify must match Node on both parse routes.
interface Point {
x: number;
y: number;
}
interface Cell {
key: string | number;
flag: boolean | null;
value?: string | number | boolean | null;
shape: Point | string;
list: number[] | string | null;
score: number | null | undefined;
}
function show(c: Cell): string {
const key = typeof c.key === "string" ? `s:${c.key}` : `n:${c.key * 2}`;
const value =
c.value === undefined
? "absent"
: c.value === null
? "null"
: typeof c.value === "string"
? `str:${c.value}`
: typeof c.value === "number"
? `num:${c.value + 1}`
: `bool:${c.value ? "t" : "f"}`;
const shape = typeof c.shape === "string" ? c.shape : `(${c.shape.x},${c.shape.y})`;
const list = c.list === null ? "nolist" : typeof c.list === "string" ? c.list : c.list.join("/");
const score = c.score === undefined ? "u" : c.score === null ? "z" : String(c.score);
return [key, c.flag, value, shape, list, score, "value" in c, "score" in c].join(" ");
}
const inputs = [
'{"key":"a","flag":true,"value":"v","shape":{"x":1,"y":2},"list":[1,2,3],"score":9.5}',
'{"key":7,"flag":null,"value":3,"shape":"circle","list":"csv","score":null}',
'{"key":-0,"flag":false,"value":true,"shape":{"y":4,"x":3},"list":null}',
'{"key":"","flag":false,"value":null,"shape":"","list":[],"score":0}',
'{"key":1e21,"flag":true,"shape":{"x":-1,"y":0.5,"z":9},"list":[0.1],"score":-1}',
'{"key":"k","flag":null,"value":false,"shape":"sq","list":"","value":"last"}',
];
for (const text of inputs) {
const c = JSON.parse(text) as Cell;
console.log(show(c));
console.log(" ", Object.keys(c).join(","), JSON.stringify(c));
}
const cells = JSON.parse("[" + inputs.join(",") + "]") as Cell[];
console.log(cells.map(show).join(" | "));
// Union arrays inside a record member, and a record arm holding unions.
interface Doc {
title: string;
meta: { owner: string | null; rev?: number } | null;
sizes: number[] | null;
}
const docs = JSON.parse(
'[{"title":"a","meta":{"owner":null},"sizes":[1,2]},' +
'{"title":"b","meta":null,"sizes":null},' +
'{"title":"c","meta":{"owner":"o","rev":3},"sizes":[]}]',
) as Doc[];
for (const d of docs) {
const meta = d.meta === null ? "-" : `${d.meta.owner ?? "nobody"}:${d.meta.rev ?? "r0"}:${"rev" in d.meta}`;
console.log(d.title, meta, d.sizes === null ? "nosizes" : d.sizes.length, JSON.stringify(d));
}