diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 731385825..9769bdb31 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -39,6 +39,7 @@ import { isDirentShape, isDynTypedRefType, isRefCounted, + isUnitType, typeKey, } from "../../ir/ir.js"; import { dynDesc, streamTypedRefEligible } from "../../ir/analysis.js"; @@ -697,6 +698,46 @@ export class LlDyn { /** Emit the standard in-helper pending check: on a pending exception * run `cleanup` and return `dummy`. */ + /** The failure block of a union dynCheck whose value matched no arm. A + * value of the one container kind exactly one arm can take (an object + * for the union's only record arm, an array for its only array arm, + * beside unit and scalar arms) runs that arm's builder, which throws the + * deepest failing path and the reason there (`expected string at + * $.order.owner.id, got undefined`) instead of the union's own outer + * path. Other values, and an arm builder that unexpectedly succeeds, + * fall through to the union's message. Only the failure path changes. */ + private unionArmFailure(B: BlockBuilder, def: IrUnionDef, kind: string): void { + const scalar = (arm: IrType): boolean => + isUnitType(arm) || + arm.kind === "f64" || + arm.kind === "bool" || + arm.kind === "string" || + arm.kind === "bigint"; + const containers = def.arms.filter((arm) => !scalar(arm)); + const arm = containers.length === 1 ? containers[0]! : null; + const container = + arm?.kind === "array" + ? DYN_KIND.ARR + : arm?.kind === "record" && !this.host.recordsById.get(arm.shapeId)?.tuple + ? DYN_KIND.OBJ + : null; + if (!arm || container === null) return; + const host = this.host; + const deep = B.newLabel("dcu.deep"); + const outer = B.newLabel("dcu.outer"); + const same = B.tmp(); + B.line(`${same} = icmp eq i32 ${kind}, ${container}`); + B.condBr(same, deep, outer); + B.startBlock(deep); + const built = B.tmp(); + B.line(`${built} = call ptr @${this.dynCheckHelper(arm)}(ptr %d, ptr %path)`); + this.pendingBail(B, "dcu.deep", () => {}, "ptr null"); + const rc = vAdapters(host, arm); + B.line(`call void ${rc.release}(ptr ${built})`); + B.br(outer); + B.startBlock(outer); + } + private pendingBail(B: BlockBuilder, hint: string, cleanup: () => void, dummy: string): void { const p = B.tmp(); for (const line of this.host.pendingTestLines(p)) B.line(line.trimStart()); @@ -2291,6 +2332,7 @@ export class LlDyn { B.line(`call void @scr_dyn_release_v(ptr ${materialized})`); B.terminate(`ret ptr ${checked}`); B.startBlock(lFail); + this.unionArmFailure(B, def, kind); B.line(`call void @scr_dyn_check_fail(ptr %path, ptr ${want}, ptr %d)`); B.terminate(`ret ptr null`); break; @@ -2359,6 +2401,7 @@ export class LlDyn { ); B.terminate(`ret ptr ${checked}`); B.startBlock(lFail); + this.unionArmFailure(B, def, kind); B.line(`call void @scr_dyn_check_fail(ptr %path, ptr ${want}, ptr %d)`); B.terminate(`ret ptr null`); break; diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 2ab323f37..6a2d4dc61 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -305,6 +305,46 @@ console.log("unreachable", c.host); ); }); + test("optional record member: the failure names the deepest path inside it", async () => { + // `Partial` makes every member an `X | undefined` union. A present + // object that fails X used to report only the union's own path + // ("expected object | undefined at $.order, got object"); the record + // arm's builder now reports where inside it the value fails. + const r = await compileAndRun( + "optional-record-deep", + `interface Order { + owner: { id: string; plan: { tier: string } }; + total: number; +} +interface Envelope { order: Order; tags?: string[] } +const texts = [ + '{"order":{"owner":{"id":"u1"},"total":3}}', + '{"order":{"owner":{"id":7,"plan":{"tier":"pro"}},"total":3}}', + '{"order":{"owner":{"id":"u1","plan":{"tier":"pro"}},"total":3},"tags":["a",2]}', + '{"order":5}', +]; +for (const text of texts) { + try { + const e = JSON.parse(text) as Partial; + console.log("ok", e.order?.owner.id); + } catch (err) { + if (err instanceof TypeError) console.log(err.message); + } +} +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + "expected object at $.order.owner.plan, got undefined", + "expected string at $.order.owner.id, got number", + "expected string at $.tags[1], got number", + "expected object | undefined at $.order, got number", + "", + ].join("\n"), + ); + }); + test("JSON null matches nothing", async () => { const r = await compileAndRun( "null-value",