diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index b85ca0062..3e8a3a29c 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -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): boolean { + private jsonSchemaSupported(t: IrType, visiting: Set, 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): 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 `_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 diff --git a/packages/compiler/test/json-schema-parse-emission.test.ts b/packages/compiler/test/json-schema-parse-emission.test.ts index 29ccc3721..69f2b39f5 100644 --- a/packages/compiler/test/json-schema-parse-emission.test.ts +++ b/packages/compiler/test/json-schema-parse-emission.test.ts @@ -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 }); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 355165333..914c841da 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -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: diff --git a/packages/runtime/src/scr_json.h b/packages/runtime/src/scr_json.h index c292646bd..85d3a09df 100644 --- a/packages/runtime/src/scr_json.h +++ b/packages/runtime/src/scr_json.h @@ -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 diff --git a/packages/runtime/src/scr_json.test.c b/packages/runtime/src/scr_json.test.c index cdf939bb9..3946331be 100644 --- a/packages/runtime/src/scr_json.test.c +++ b/packages/runtime/src/scr_json.test.c @@ -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++) { diff --git a/tests/corpus/json-typed-parse-optional-members.ts b/tests/corpus/json-typed-parse-optional-members.ts new file mode 100644 index 000000000..4b3c9ac35 --- /dev/null +++ b/tests/corpus/json-typed-parse-optional-members.ts @@ -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)); diff --git a/tests/corpus/json-typed-parse-union-members.ts b/tests/corpus/json-typed-parse-union-members.ts new file mode 100644 index 000000000..3c5807d2b --- /dev/null +++ b/tests/corpus/json-typed-parse-union-members.ts @@ -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)); +}