diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index 4f152e4bb..001880a38 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -16,6 +16,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records | | `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables | | `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation | +| `json-unions` | cpu | `JSON.parse` into discriminated unions: an event log with eight kinds and nested expression trees with nine, evaluated after the cast | | `log-lines` | cpu | 200k immediate `console.log`/`process.stdout.write` lines into the runner's pipe | | `log-summary`, `inventory-report` | cpu | the multi-module applications from `benchmarks/builds`, on 300k-line inputs | | `number-maps` | cpu | number-keyed Map/Set: a dense-id table, sparse ids in many small maps, get/has/set mixes | diff --git a/benchmarks/runtime/workloads/json-unions.json b/benchmarks/runtime/workloads/json-unions.json new file mode 100644 index 000000000..925e8457b --- /dev/null +++ b/benchmarks/runtime/workloads/json-unions.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/json-unions.ts", + "args": [ + "6" + ] +} diff --git a/benchmarks/runtime/workloads/json-unions.ts b/benchmarks/runtime/workloads/json-unions.ts new file mode 100644 index 000000000..0f684c35f --- /dev/null +++ b/benchmarks/runtime/workloads/json-unions.ts @@ -0,0 +1,155 @@ +// JSON text parsed into discriminated unions: an event log with eight kinds +// of event and a nested expression tree with nine kinds of node, cast with +// `JSON.parse(text) as T`, then aggregated and evaluated. Argument: the +// number of rounds. +type Event = + | { kind: "click"; target: string; x: number; y: number } + | { key: string; kind: "key"; repeat: boolean } + | { kind: "scroll"; offset: number } + | { kind: "resize"; height: number; width: number } + | { kind: "focus"; target: string } + | { kind: "blur"; target: string } + | { kind: "load"; ms: number; url: string } + | { code: number; kind: "error"; message: string }; + +type Expr = + | { kind: "num"; value: number } + | { kind: "var"; name: string } + | { kind: "neg"; operand: Expr } + | { kind: "add"; left: Expr; right: Expr } + | { kind: "sub"; left: Expr; right: Expr } + | { kind: "mul"; left: Expr; right: Expr } + | { kind: "max"; items: Expr[] } + | { else: Expr; kind: "if"; test: Expr; then: Expr } + | { body: Expr; kind: "let"; name: string; value: Expr }; + +let seed = 12345; +function random(): number { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + return seed / 4294967296; +} + +const targets = ["menu", "search", "button", "list", "footer"]; +function eventText(i: number): string { + const target = targets[i % targets.length]!; + switch (i % 8) { + case 0: + return `{"kind":"click","target":"${target}","x":${i % 640},"y":${i % 480}}`; + case 1: + return `{"key":"k${i % 26}","kind":"key","repeat":${i % 3 === 0}}`; + case 2: + return `{"kind":"scroll","offset":${(i * 7) % 2000}}`; + case 3: + return `{"height":${400 + (i % 300)},"kind":"resize","width":${600 + (i % 500)}}`; + case 4: + return `{"kind":"focus","target":"${target}"}`; + case 5: + return `{"kind":"blur","target":"${target}"}`; + case 6: + return `{"kind":"load","ms":${i % 900},"url":"/page/${i % 50}"}`; + default: + return `{"code":${400 + (i % 100)},"kind":"error","message":"failed ${i % 13}"}`; + } +} + +const names = ["a", "b", "c", "d"]; +function exprText(depth: number): string { + const pick = depth <= 0 ? Math.floor(random() * 2) : Math.floor(random() * 9); + switch (pick) { + case 0: + return `{"kind":"num","value":${Math.floor(random() * 100)}}`; + case 1: + return `{"kind":"var","name":"${names[Math.floor(random() * names.length)]!}"}`; + case 2: + return `{"kind":"neg","operand":${exprText(depth - 1)}}`; + case 3: + case 4: + case 5: { + const kind = pick === 3 ? "add" : pick === 4 ? "sub" : "mul"; + return `{"kind":"${kind}","left":${exprText(depth - 1)},"right":${exprText(depth - 1)}}`; + } + case 6: + return `{"items":[${exprText(depth - 1)},${exprText(depth - 2)},${exprText(depth - 2)}],"kind":"max"}`; + case 7: + return `{"else":${exprText(depth - 2)},"kind":"if","test":${exprText(depth - 1)},"then":${exprText(depth - 2)}}`; + default: + return `{"body":${exprText(depth - 1)},"kind":"let","name":"${names[Math.floor(random() * names.length)]!}","value":${exprText(depth - 2)}}`; + } +} + +function evaluate(e: Expr, env: Map): number { + switch (e.kind) { + case "num": + return e.value; + case "var": + return env.get(e.name) ?? 1; + case "neg": + return -evaluate(e.operand, env); + case "add": + return (evaluate(e.left, env) + evaluate(e.right, env)) % 1000003; + case "sub": + return (evaluate(e.left, env) - evaluate(e.right, env)) % 1000003; + case "mul": + return (evaluate(e.left, env) * evaluate(e.right, env)) % 1000003; + case "max": { + let best = -Infinity; + for (const item of e.items) best = Math.max(best, evaluate(item, env)); + return best; + } + case "if": + return evaluate(e.test, env) > 0 ? evaluate(e.then, env) : evaluate(e.else, env); + case "let": { + const inner = new Map(env); + inner.set(e.name, evaluate(e.value, env)); + return evaluate(e.body, inner); + } + } +} + +const rounds = Number(process.argv[2] ?? "6"); +const eventsText = `[${Array.from({ length: 60000 }, (_, i) => eventText(i)).join(",")}]`; +const treesText = `[${Array.from({ length: 400 }, () => exprText(9)).join(",")}]`; +let clicks = 0; +let keys = 0; +let scrolled = 0; +let area = 0; +let loadMs = 0; +let errors = 0; +let focus = 0; +let total = 0; +for (let round = 0; round < rounds; round++) { + const events = JSON.parse(eventsText) as Event[]; + for (const event of events) { + switch (event.kind) { + case "click": + clicks += event.x + event.y; + break; + case "key": + if (event.repeat) keys++; + break; + case "scroll": + scrolled += event.offset; + break; + case "resize": + area += (event.width * event.height) % 1000; + break; + case "focus": + case "blur": + focus += event.target.length; + break; + case "load": + loadMs += event.ms; + break; + case "error": + errors += event.code % 7; + break; + } + } + const trees = JSON.parse(treesText) as Expr[]; + const env = new Map([ + ["a", 3], + ["b", 5], + ]); + for (const tree of trees) total = (total + evaluate(tree, env)) % 1000003; +} +console.log(clicks, keys, scrolled, area, loadMs, errors, focus, total); diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index d90239fc3..3d7533f22 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -193,6 +193,11 @@ function builtinStreamKind(className: string): number | null { return kinds[className] ?? null; } +/** Unions whose discriminant has fewer string literals keep one literal test + * per arm (dispatchValues): one runtime lookup costs about what a few + * literal tests do. */ +const DISCRIMINANT_DISPATCH_VALUES = 4; + export class LlDyn { private readonly dynMatchers = new Map(); private readonly dynBuilders = new Map(); @@ -413,6 +418,86 @@ export class LlDyn { return order; } + /** The string literals of `def`'s discriminant when its arms are chosen + * by reading the discriminant once (unionArmsByDiscriminant), or null + * when the union keeps one literal test per arm: some literal is not a + * string, or there are fewer than DISCRIMINANT_DISPATCH_VALUES. */ + private dispatchValues(def: IrUnionDef): string[] | null { + const values: string[] = []; + for (const guard of def.discriminant?.cases ?? []) { + if (guard.values.length === 0) return null; + for (const value of guard.values) { + if (typeof value !== "string") return null; + if (!values.includes(value)) values.push(value); + } + } + return values.length < DISCRIMINANT_DISPATCH_VALUES ? null : values; + } + + /** Branches to `hits[tag]` for the first arm in `order` that `%d` matches + * (unionArmMatch), or to `none` when no arm does, for + * a union whose discriminant literals are `values` (dispatchValues). The + * discriminant is read once (scr_dyn_discriminant_case, against the + * constant `table`), and only the arms whose literal it holds and the arms + * without a literal are tested, in `order`: every other arm's literal test + * fails. A value the runtime cannot classify that way (a typed capsule, an + * accessor, an inherited discriminant) takes the tests arm by arm. */ + private unionArmsByDiscriminant( + B: BlockBuilder, + def: IrUnionDef, + values: readonly string[], + order: readonly number[], + hits: readonly string[], + none: string, + preserveRefs: boolean, + table: string, + ): void { + const discriminant = def.discriminant!; + const literalsOf = (tag: number): readonly (string | number | boolean)[] | null => + discriminant.cases.find((candidate) => candidate.tag === tag)?.values ?? null; + const tryArms = (tags: readonly number[], literal: boolean): void => { + if (tags.length === 0) { + B.br(none); + return; + } + tags.forEach((tag, index) => { + const next = index === tags.length - 1 ? none : B.newLabel("du.c.n"); + if (literal) this.unionArmMatch(B, def, tag, hits[tag]!, next, preserveRefs); + else this.unionArmStructure(B, def, tag, hits[tag]!, next, preserveRefs); + if (next !== none) B.startBlock(next); + }); + }; + const armsFor = (value: string | null): number[] => + order.filter((tag) => { + const literals = literalsOf(tag); + return literals === null || (value !== null && literals.includes(value)); + }); + const host = this.host; + this.defs.push( + `${table} = private unnamed_addr constant [${values.length} x ptr] [${values.map((value) => `ptr ${host.internLiteral(value)}`).join(", ")}]`, + ``, + ); + host.declare(`declare i32 @scr_dyn_discriminant_case(ptr, ptr, ptr, ${host.sizeType})`); + const index = B.tmp(); + B.line( + `${index} = call i32 @scr_dyn_discriminant_case(ptr %d, ptr ${host.internLiteral(discriminant.field)}, ptr ${table}, ${host.sizeType} ${values.length})`, + ); + const tests = B.newLabel("du.tests"); + const unmatched = B.newLabel("du.none"); + const cases = values.map(() => B.newLabel("du.case")); + B.terminate( + `switch i32 ${index}, label %${tests} [ i32 -1, label %${unmatched} ${cases.map((label, k) => `i32 ${k}, label %${label}`).join(" ")} ]`, + ); + values.forEach((value, k) => { + B.startBlock(cases[k]!); + tryArms(armsFor(value), false); + }); + B.startBlock(unmatched); + tryArms(armsFor(null), false); + B.startBlock(tests); + tryArms(order, true); + } + /** Branch to a variant only after its literal discriminator and complete * structure match. Use the same path for predicates and builders. */ private unionArmMatch( @@ -450,6 +535,18 @@ export class LlDyn { }); B.startBlock(structure); } + this.unionArmStructure(B, def, tag, yes, no, preserveRefs); + } + + /** The part of unionArmMatch past the arm's literal test. */ + private unionArmStructure( + B: BlockBuilder, + def: IrUnionDef, + tag: number, + yes: string, + no: string, + preserveRefs: boolean, + ): void { const matches = B.tmp(); B.line( `${matches} = call zeroext i1 @${this.dynMatchHelper(def.arms[tag]!, preserveRefs)}(ptr %d)`, @@ -1275,11 +1372,26 @@ export class LlDyn { ); // Arms in canonical order; any full match answers true. const yes = B.newLabel("dm.y"); - for (let tag = 0; tag < def.arms.length; tag++) { - const ln = B.newLabel("dm.n"); - this.unionArmMatch(B, def, tag, yes, ln, preserveRefs); - B.startBlock(ln); - } + const values = this.dispatchValues(def); + if (values !== null) { + const no = B.newLabel("dm.n"); + this.unionArmsByDiscriminant( + B, + def, + values, + def.arms.map((_, tag) => tag), + def.arms.map(() => yes), + no, + preserveRefs, + `@${name}.cases`, + ); + B.startBlock(no); + } else + for (let tag = 0; tag < def.arms.length; tag++) { + const ln = B.newLabel("dm.n"); + this.unionArmMatch(B, def, tag, yes, ln, preserveRefs); + B.startBlock(ln); + } B.terminate(`ret i1 false`); B.startBlock(yes); B.terminate(`ret i1 true`); @@ -2459,12 +2571,8 @@ export class LlDyn { ); // Arms in unionArmOrder, first FULL match wins. The matched arm's // builder can no longer fail. - for (const i of this.unionArmOrder(def)) { + const build = (i: number): void => { const arm = def.arms[i]!; - const lHit = B.newLabel("dcu.h"); - const lNext = B.newLabel("dcu.n"); - this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs); - B.startBlock(lHit); if (arm.kind === "undefinedT" || arm.kind === "nullT") { // A matched unit arm builds nothing: THE interned immortal // instance (rc == SIZE_MAX — no retain owed). @@ -2503,8 +2611,36 @@ export class LlDyn { ); B.terminate(`ret ptr ${u}`); } - B.startBlock(lNext); - } + }; + const order = this.unionArmOrder(def); + const values = this.dispatchValues(def); + if (values !== null) { + const hits = def.arms.map(() => B.newLabel("dcu.h")); + const unmatched = B.newLabel("dcu.n"); + this.unionArmsByDiscriminant( + B, + def, + values, + order, + hits, + unmatched, + preserveRefs, + `@${name}.cases`, + ); + for (const i of order) { + B.startBlock(hits[i]!); + build(i); + } + B.startBlock(unmatched); + } else + for (const i of order) { + const lHit = B.newLabel("dcu.h"); + const lNext = B.newLabel("dcu.n"); + this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs); + B.startBlock(lHit); + build(i); + B.startBlock(lNext); + } if (preserveRefs) { B.line( `call void @scr_dyn_check_fail(ptr %path, ptr ${host.cstr("reference with its original layout")}, ptr %d)`, diff --git a/packages/compiler/test/chunked-emission.test.ts b/packages/compiler/test/chunked-emission.test.ts index b4912c9f6..2aec1e49b 100644 --- a/packages/compiler/test/chunked-emission.test.ts +++ b/packages/compiler/test/chunked-emission.test.ts @@ -9,9 +9,10 @@ import { inlineRcOption } from "../src/backend/llvm/shapes.js"; // Programs whose function bodies request module-level helpers and tables // that the chunk journal (emit-journal.ts) carries: checked-dynamic -// conversions, inspection, publication walkers, live record views, stream -// adapters, regex instances, colliding literal digests, interned C strings, -// speculative loop sites, typed message listeners and readers. +// conversions (with their discriminant tables), inspection, publication +// walkers, live record views, stream adapters, regex instances, colliding +// literal digests, interned C strings, speculative loop sites, typed message +// listeners and readers. const PROGRAMS = [ "1003-json-parse-unions.ts", "114-live-record-delete.ts", @@ -21,6 +22,7 @@ const PROGRAMS = [ "4900-console-instances.ts", "5612-readable-from-iterables.cjs", "5702-publish-candidate-buffers.ts", + "6305-json-discriminant-dispatch.ts", "7321-speculative-loop-bail-sites.ts", "7360-worker-typed-records.ts", "node24-text-utilities-coercion.js", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6305-json-discriminant-dispatch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6305-json-discriminant-dispatch.ts.json new file mode 100644 index 000000000..b83ada276 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6305-json-discriminant-dispatch.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6305-json-discriminant-dispatch.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index efb83d9fc..d46764d3d 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -677,6 +677,14 @@ void scr_dyn_copy_entry(ScrDyn *target, const ScrDyn *source, const ScrDynEntry bool scr_dyn_field_eq_str(const ScrDyn *d, const ScrStr *key, const ScrStr *value); bool scr_dyn_field_eq_num(const ScrDyn *d, const ScrStr *key, double value); bool scr_dyn_field_eq_bool(const ScrDyn *d, const ScrStr *key, bool value); +/* The case a union's checked match takes from its string discriminant: the + * index in `values` of the string `d`'s own data property `key` holds, or + * -1 when every literal test above fails (`d` is not an object, or its own + * data property `key` holds no string among `values`), or -2 when only the + * tests themselves can tell (a typed capsule, an accessor, or a property + * `d` does not own). Borrowed args. */ +int32_t scr_dyn_discriminant_case(const ScrDyn *d, const ScrStr *key, ScrStr *const *values, + size_t count); /* Object.keys/values/entries over the checked-dynamic tree: JS own-key order (array-index * keys ascending first), dyn-array results (+1); values/entries RETAIN diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 580b816c4..0ba6ec28d 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1422,6 +1422,24 @@ bool scr_dyn_field_eq_num(const ScrDyn *d, const ScrStr *key, double value) { return matches; } +int32_t scr_dyn_discriminant_case(const ScrDyn *d, const ScrStr *key, ScrStr *const *values, + size_t count) { + if (!d) return -1; + if (d->kind == SCR_DYN_TYPED_REF) return -2; + if (d->kind != SCR_DYN_OBJ) return -1; + for (size_t i = 0; i < d->v.obj.len; i++) { + const ScrDynEntry *entry = &d->v.obj.entries[i]; + if (entry->key_len != key->len || memcmp(entry->key, key->data, key->len) != 0) continue; + if (entry->accessor) return -2; + const ScrDyn *member = entry->value; + if (!member || member->kind != SCR_DYN_STR) return -1; + for (size_t k = 0; k < count; k++) + if (scr_str_eq(member->v.str, values[k])) return (int32_t)k; + return -1; + } + return -2; +} + bool scr_dyn_field_eq_bool(const ScrDyn *d, const ScrStr *key, bool value) { ScrDyn *member = scr_dyn_discriminant(d, key); bool matches = member && member->kind == SCR_DYN_BOOL && member->v.b == value; diff --git a/tests/corpus/6305-json-discriminant-dispatch.ts b/tests/corpus/6305-json-discriminant-dispatch.ts new file mode 100644 index 000000000..ac4d992da --- /dev/null +++ b/tests/corpus/6305-json-discriminant-dispatch.ts @@ -0,0 +1,123 @@ +// JSON cast into a union with many string discriminant literals: the cast +// reads the discriminant once and tests only the arm whose literal it +// holds, plus the arms that are not records. An arm can list several +// literals, and values that are not objects reach the other arms in +// canonical order. Valid casts only (see 1002-json-parse-cast.ts); JSON keys +// and record fields alphabetical for stringify parity. + +type Expr = + | { kind: "num"; value: number } + | { kind: "str"; text: string } + | { kind: "neg"; operand: Expr } + | { kind: "add" | "mul"; left: Expr; right: Expr } + | { args: Expr[]; kind: "call"; name: string } + | { kind: "method"; name: string; target: Expr } + | { kind: "list"; items: Expr[] }; + +function show(e: Expr): string { + switch (e.kind) { + case "num": + return String(e.value); + case "str": + return JSON.stringify(e.text); + case "neg": + return `-(${show(e.operand)})`; + case "add": + return `(${show(e.left)} + ${show(e.right)})`; + case "mul": + return `(${show(e.left)} * ${show(e.right)})`; + case "call": + return `${e.name}(${e.args.map(show).join(", ")})`; + case "method": + return `${show(e.target)}.${e.name}()`; + case "list": + return `[${e.items.map(show).join(", ")}]`; + } +} + +function evaluate(e: Expr): number { + switch (e.kind) { + case "num": + return e.value; + case "str": + return e.text.length; + case "neg": + return -evaluate(e.operand); + case "add": + return evaluate(e.left) + evaluate(e.right); + case "mul": + return evaluate(e.left) * evaluate(e.right); + case "call": + return e.args.reduce((sum, arg) => sum + evaluate(arg), 0); + case "method": + return evaluate(e.target); + case "list": + return e.items.length; + } +} + +const source = + '{"kind":"add","left":{"kind":"mul","left":{"kind":"num","value":3},' + + '"right":{"kind":"neg","operand":{"kind":"num","value":4}}},' + + '"right":{"args":[{"kind":"str","text":"abc"},{"kind":"num","value":2}],"kind":"call","name":"sum"}}'; +const tree = JSON.parse(source) as Expr; +console.log(show(tree)); +console.log(evaluate(tree)); +console.log(JSON.stringify(tree) === source); + +const method = JSON.parse( + '{"kind":"method","name":"size","target":{"items":[{"kind":"num","value":1}],"kind":"list"}}', +) as Expr; +console.log(show(method), evaluate(method)); + +// A deep list of every arm, built from text. +const parts: string[] = []; +for (let i = 0; i < 200; i++) { + parts.push( + i % 3 === 0 + ? `{"kind":"num","value":${i}}` + : i % 3 === 1 + ? `{"kind":"neg","operand":{"kind":"num","value":${i}}}` + : `{"kind":"mul","left":{"kind":"num","value":${i}},"right":{"kind":"str","text":"xy"}}`, + ); +} +const list = JSON.parse(`{"items":[${parts.join(",")}],"kind":"list"}`) as Expr; +if (list.kind === "list") { + let total = 0; + for (const item of list.items) total += evaluate(item); + console.log(list.items.length, total); +} + +// Arms that are not records: a value that is not an object can only be one +// of them. +type Token = + | { kind: "ident"; name: string } + | { kind: "number"; value: number } + | { kind: "string"; value: string } + | { kind: "punct"; symbol: string } + | { kind: "comment"; line: number; text: string } + | string + | number; + +function describe(token: Token): string { + if (typeof token === "string") return `raw ${token}`; + if (typeof token === "number") return `count ${token}`; + switch (token.kind) { + case "ident": + return `ident ${token.name}`; + case "number": + return `number ${token.value}`; + case "string": + return `string ${JSON.stringify(token.value)}`; + case "punct": + return `punct ${token.symbol}`; + case "comment": + return `comment ${token.line}: ${token.text}`; + } +} + +const tokens = JSON.parse( + '[{"kind":"ident","name":"x"},{"kind":"punct","symbol":"="},{"kind":"number","value":42},' + + '{"kind":"string","value":"hi"},{"kind":"comment","line":3,"text":"note"},"spare",7]', +) as Token[]; +for (const token of tokens) console.log(describe(token)); diff --git a/tests/harness/corpus-costs/6305-json-discriminant-dispatch.ts.json b/tests/harness/corpus-costs/6305-json-discriminant-dispatch.ts.json new file mode 100644 index 000000000..7e2a9d53c --- /dev/null +++ b/tests/harness/corpus-costs/6305-json-discriminant-dispatch.ts.json @@ -0,0 +1 @@ +{ "plain": 5844, "san": 7609 } diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 6a2d4dc61..5559be970 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -396,6 +396,54 @@ console.log("unreachable", s.kind); expect(r.stderr).toContain("Uncaught TypeError: expected object | object at $, got object"); }); + test("a union read by its discriminant fails with every arm named, as arm-by-arm tests do", async () => { + // Five record arms with six literals, plus string and number arms: the + // check reads `kind` once and tests only the arm it names and the + // arms that are not records. + const r = await compileAndRun( + "union-discriminant-dispatch", + `type Expr = + | { kind: "num"; value: number } + | { kind: "str"; text: string } + | { kind: "neg"; operand: Expr } + | { kind: "add" | "mul"; left: Expr; right: Expr } + | { items: Expr[]; kind: "list" } + | string + | number; +const inputs = [ + '{"kind":"num"}', + '{"kind":"nope","value":1}', + '{"kind":7,"value":1}', + '{"value":1}', + '{"kind":"neg","operand":{"kind":"add","left":{"kind":"num","value":1},"right":{"kind":"str","text":5}}}', + 'true', + '{"items":["a",3,{"kind":"mul","left":"x","right":2}],"kind":"list"}', +]; +for (const input of inputs) { + try { + console.log(JSON.stringify(JSON.parse(input) as Expr)); + } catch (error) { + console.log((error as Error).message); + } +} +`, + ); + const any = "number | object | object | object | object | object | string"; + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + `expected ${any} at $, got object`, + `expected ${any} at $, got boolean`, + '{"items":["a",3,{"kind":"mul","left":"x","right":2}],"kind":"list"}', + "", + ].join("\n"), + ); + }); + test("nested paths point at the offending element", async () => { const r = await compileAndRun( "nested-path",