diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 3987c52c2..136798796 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1563,6 +1563,19 @@ export class LlDyn { return descriptor; } + /** `record.sourceSpread(built, value)`: the built record keeps value's + * kept-source order over its own declared names. */ + emitRecordSourceSpread(B: BlockBuilder, built: string, value: string, shapeId: string): void { + const host = this.host; + const shape = host.recordsById.get(shapeId)!; + this.keepsRecordSources = true; + const names = this.recordNameTable(shapeId, shape); + host.declare(`declare void @scr_record_source_spread(ptr, ptr, ptr, ptr, ${host.sizeType})`); + B.line( + `call void @scr_record_source_spread(ptr ${built}, ptr ${value}, ptr ${names.names}, ptr ${names.lens}, ${host.sizeType} ${shape.fields.length})`, + ); + } + /** 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/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 199e67bab..b7f383921 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -239,6 +239,15 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll host.emitPendingCheck(); return output; } + if (e.fn === "record.sourceSpread") { + const built = host.emitReadReceiver(e.args[0]!); + const value = host.emitReadReceiver(e.args[1]!); + const type = e.args[0]!.type; + if (type.kind !== "record") + throw new InternalCompilerError("llvm emitter bug: record.sourceSpread of a non-record"); + host.dyn.emitRecordSourceSpread(B, built.name, value.name, type.shapeId); + return { name: "", type: e.type }; + } if (e.fn === "dyn.typedRefIs" || e.fn === "dyn.typedRefKeyIs") { const literal = e.args[1]!.kind === "strLit" ? e.args[1]!.value : null; if (e.fn === "dyn.typedRefKeyIs" && literal === null) diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 7a48ec7b6..9cdc36cf5 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -678,6 +678,7 @@ export const LIB_FN_SYMS: Record = { "insp.circular": "scr_insp_circular", // Undeclared source properties kept beside a checked record (scr_json.c). "record.sourceHas": "scr_record_source_has", + "record.sourceSpread": "scr_record_source_spread", // WHATWG URL + URLSearchParams (scr_url.c / scr_url_params.c): // constructions +1; url.new, the fileURLToPath pair, the win32 // pathToFileURL flavor, and sp.fromPairs throw catchably (may-throw). diff --git a/packages/compiler/src/frontend/lowering/key-order-witness.test.ts b/packages/compiler/src/frontend/lowering/key-order-witness.test.ts index 733888763..d68be96a8 100644 --- a/packages/compiler/src/frontend/lowering/key-order-witness.test.ts +++ b/packages/compiler/src/frontend/lowering/key-order-witness.test.ts @@ -42,6 +42,7 @@ function fixture(): Fixture { unions: new Map([["u0", { id: "u0", arms: [F64, { kind: "undefinedT" }] }]]), classes: new Map(), methodAccessSites: new Map(), + dynConvertible: () => true, armTag: (unionId: string, arm: IrType): number => unionId === "u0" && arm.kind === "undefinedT" ? 1 : -1, recordFieldPresent: (obj: IrExpr, shapeId: string, field: string): IrExpr => ({ @@ -873,3 +874,92 @@ test("a generator resume builds only its result record, never the value it carri // The result record itself is built in its fixed layout: today's table. expect(f.order(result)).toEqual(["value", "done"]); }); + +/** `{ a, b }` built by a checked conversion and by the literal `{ b, a }`, + * copied by `{ ...value, a }` (evaluating the value into a binding, as + * spread literals do), observed. Answers the table and the function. */ +function checkedSelfCopy(): { order: string[]; fn: IrFunction; copy: IrExpr } { + const f = fixture(); + const ab = f.record( + [ + ["a", F64], + ["b", F64], + ], + ["a", "b"], + ); + const value = ref("value", ab) as IrExpr & { kind: "varRef" }; + const self: KeyOrderSegment = { kind: "spread", shapeId: ab.shapeId, maybeAbsent: false, value }; + const literal = f.literal(ab, ["a", "b"], [self, { kind: "key", name: "a", definite: true }]); + const copy: IrExpr = { + kind: "seqExpr", + stmts: [decl("value", { kind: "dynCheck", value: ref("j", { kind: "dyn" }), type: ab, loc })], + result: literal, + type: ab, + loc, + }; + const fn = f.run([observe(f.literal(ab, ["b", "a"])), observe(copy)]); + return { order: f.order(ab)!, fn, copy }; +} + +test("a spread of a value a checked conversion built carries the value's order over", () => { + const { order, fn, copy } = checkedSelfCopy(); + // Values the literal builds enumerate in its order; copies of converted + // values keep the order their record source holds. + expect(order).toEqual(["b", "a"]); + expect(copy).toMatchObject({ + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: "value" }, + { kind: "varDecl", localId: "%orderedSpread.0", init: { kind: "recordLit" } }, + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "record.sourceSpread", + args: [ + { kind: "varRef", localId: "%orderedSpread.0" }, + { kind: "varRef", localId: "value" }, + ], + }, + }, + ], + result: { kind: "varRef", localId: "%orderedSpread.0" }, + }); + expect(fn.locals.map((local) => local.id)).toEqual(["%orderedSpread.0"]); +}); + +test("a spread of a converted value into a wider shape orders the wider shape", () => { + const f = fixture(); + const ab = f.record( + [ + ["a", F64], + ["b", F64], + ], + ["a", "b"], + ); + const abc = f.record( + [ + ["a", F64], + ["b", F64], + ["c", F64], + ], + ["c", "a", "b"], + ); + const value = ref("value", ab) as IrExpr & { kind: "varRef" }; + const spread: KeyOrderSegment = { + kind: "spread", + shapeId: ab.shapeId, + maybeAbsent: false, + value, + }; + const wide: IrExpr = { + kind: "seqExpr", + stmts: [decl("value", { kind: "dynCheck", value: ref("j", { kind: "dyn" }), type: ab, loc })], + result: f.literal(abc, ["a", "b", "c"], [spread, { kind: "key", name: "c", definite: true }]), + type: abc, + loc, + }; + f.run([observe(f.literal(ab, ["b", "a"])), observe(wide)]); + expect(f.order(ab)).toEqual(["b", "a"]); + expect(f.order(abc)).toEqual(["b", "a", "c"]); +}); diff --git a/packages/compiler/src/frontend/lowering/key-order-witness.ts b/packages/compiler/src/frontend/lowering/key-order-witness.ts index 4a0a1309f..6e65d6f6d 100644 --- a/packages/compiler/src/frontend/lowering/key-order-witness.ts +++ b/packages/compiler/src/frontend/lowering/key-order-witness.ts @@ -41,6 +41,12 @@ * - a literal that starts by spreading a value of its own shape copies * that value's order. While no checked conversion builds the shape, * every value has the one chosen order, so the copy adds no witness; + * - a literal that starts by spreading a value of a shape checked + * conversions build (an ordered spread) gives the record it builds the + * value's record source, when the value has one (record.sourceSpread). + * Every value of a shape enumerates in its table or in the order its + * record source holds, so the copy has the value's order either way, + * and the literal resolves as if the value were in its table's order; * - a write to an optional key that may be absent appends it in Node. A * key no construction creates ("late-only") goes after every key a * construction can create; any other moving write goes after every key. @@ -61,6 +67,7 @@ import { shapeHasAccessorSlots, typeEquals, UNDEFINED_T, + VOID, } from "../../ir/ir.js"; import { everyExprChild, @@ -71,6 +78,7 @@ import { import type { Lowerer } from "./lowerer.js"; import * as ts from "../ts7/adapter.js"; import { ALWAYS_THROW_LIB_FNS } from "../../ir/builtin-effects.js"; +import { recordMayKeepSource } from "./record-sources.js"; /** The statements a key-order helper runs for record `ref`: `walks[0]`, * its walk over the shape's table, or, for a shape with orderVariants, a @@ -218,6 +226,14 @@ interface ShapeFacts { type Resolved = { order: string[] } | "unknown"; +/** A literal that starts with `...value`: the sequence that evaluates the + * value into its binding and then builds the literal, and its function. */ +interface LeadingSpread { + wrapper: IrExpr & { kind: "seqExpr" }; + literal: IrExpr & { kind: "recordLit" }; + fn: IrFunction; +} + /** `structuredClone(v)` checked back into v's own type: the copy is built * from v's dyn view, so it creates exactly v's keys in v's order. */ function orderPreservingClone(node: IrExpr & { kind: "dynCheck" }): boolean { @@ -412,6 +428,12 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio // ── collection ────────────────────────────────────────────────────── let pos = 0; let fnName = ""; + let currentFn: IrFunction | null = null; + /** Literals that start by spreading a binding, by their witness: where + * each one is built. A literal's notes are shared by its rebuilt copies, + * so `literalSites` counts every literal of each witness. */ + const leadingSpreads = new Map(); + const literalSites = new Map(); const loops: number[] = []; let loopIds = 0; const loopKey = (): string => loops.join("/"); @@ -463,8 +485,10 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio } const segments = notes.literalOf(e); const copy = notes.copyOf(e); - if (segments !== undefined) f.literals.push(segments); - else if (copy !== undefined) f.copies.push({ source: copy, keys: presentKeys }); + if (segments !== undefined) { + f.literals.push(segments); + literalSites.set(segments, (literalSites.get(segments) ?? 0) + 1); + } else if (copy !== undefined) f.copies.push({ source: copy, keys: presentKeys }); else f.pinned = true; return; } @@ -479,6 +503,18 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio case "assignExpr": noteInit(e.localId, e.value); return; + case "seqExpr": { + // A spread literal evaluates its sources into bindings first. + const literal = e.result; + if (literal.kind !== "recordLit" || currentFn === null) return; + const segments = notes.literalOf(literal); + const first = segments?.[0]; + if (segments === undefined || first?.kind !== "spread" || first.value === undefined) return; + const list = leadingSpreads.get(segments) ?? []; + list.push({ wrapper: e, literal, fn: currentFn }); + leadingSpreads.set(segments, list); + return; + } case "jsonStringify": case "dynFrom": observedRoots.push(e.value.type); @@ -582,6 +618,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio }; for (const fn of fns) { fnName = fn.name; + currentFn = fn; for (const st of fn.body) stmt(st); } for (const id of opaqueShapes) { @@ -698,6 +735,61 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio return edges; }; + // ── ordered spreads ───────────────────────────────────────────────── + // A value a checked conversion builds keeps its source's key order in + // its record source when that order is not the table's (record- + // sources.ts), so every value of a shape enumerates in the table's order + // or in the order its record source holds. A literal that starts with + // `...value` creates the value's keys first, in the value's order. When + // the value's shape is one checked conversions build, the literal + // carries that order over: after it builds the record it gives the + // record a source holding the value's order (record.sourceSpread), and + // the keys the value lacks follow in the table's order, which every + // literal sequence of the shape agrees with. Such a literal resolves as + // if its source were in the source's table order, and its own shape + // counts as built at run time too, since its values may now carry a + // source. It qualifies when every value of the source has all of the + // source's keys, both shapes can keep a source, and every later spread + // whose values may carry a source only overwrites the source's keys + // (its own order cannot move them). + const orderedSpreads = new Set(); + const keepsSource = (shapeId: string): boolean => + recordMayKeepSource(lowerer, { kind: "record", shapeId }); + const userKeys = (shapeId: string): string[] => + (shapes.get(shapeId)?.fields ?? []).map((x) => x.name).filter((k) => !k.startsWith("%")); + const ordersSpread = (segments: KeyOrderSegment[], sites: readonly LeadingSpread[]): boolean => { + const first = segments[0]; + if (first?.kind !== "spread" || first.maybeAbsent || first.value === undefined) return false; + const sourceId = first.shapeId; + if (facts.get(sourceId)?.runtime !== true || sites.length !== literalSites.get(segments)) + return false; + const target = sites[0]!.literal.type; + if (target.kind !== "record" || !keepsSource(sourceId) || !keepsSource(target.shapeId)) + return false; + const localId = first.value.localId; + const bound = ({ wrapper, literal }: LeadingSpread): boolean => + typeEquals(literal.type, target) && + wrapper.stmts.some( + (st) => + st.kind === "varDecl" && + st.localId === localId && + st.init !== null && + st.init.type.kind === "record" && + st.init.type.shapeId === sourceId, + ); + if (!sites.every(bound)) return false; + const sourceKeys = new Set(userKeys(sourceId)); + if ([...sourceKeys].some((k) => maybeAbsent(sourceId, k))) return false; + return segments + .slice(1) + .every( + (s) => + s.kind === "key" || + (s.kind === "spread" && + (facts.get(s.shapeId)?.runtime !== true || + userKeys(s.shapeId).every((k) => sourceKeys.has(k)))), + ); + }; // ── witness sequences ─────────────────────────────────────────────── /** The key sequences one literal can create, or null when a spread * source's order is unknown. A key sits at its first occurrence that @@ -707,15 +799,19 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio segments: readonly KeyOrderSegment[], keySet: ReadonlySet, resolved: (id: string) => Resolved, + raw: (id: string) => Resolved, ): string[][] | null => { const occurrences: { key: string; definite: boolean }[] = []; + const ordered = orderedSpreads.has(segments); for (const s of segments) { if (s.kind === "opaque") return null; if (s.kind === "key") { if (keySet.has(s.name)) occurrences.push({ key: s.name, definite: s.definite }); continue; } - const source = resolved(s.shapeId); + // An ordered spread's values without a source enumerate in the + // source's table; the others carry their order over. + const source = ordered && s === segments[0] ? raw(s.shapeId) : resolved(s.shapeId); if (source === "unknown") return null; for (const k of source.order) { if (keySet.has(k)) @@ -809,13 +905,17 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio first.kind === "spread" && first.shapeId === id && !first.maybeAbsent && - !f.runtime && + (!f.runtime || orderedSpreads.has(segments)) && segments.every((s) => s.kind !== "opaque") && table.every((k) => !maybeAbsent(id, k)) ); }; /** One shape's order given its sources' current orders. */ - const resolveOne = (id: string, current: (source: string) => Resolved): Resolved => { + const resolveOne = ( + id: string, + current: (source: string) => Resolved, + raw: (source: string) => Resolved, + ): Resolved => { const shape = shapes.get(id); if (!inScope(shape)) return "unknown"; const table = tableOf(shape); @@ -832,7 +932,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio if (f.pinned) chain(table, edges); for (const segments of f.literals) { if (copiesItself(id, f, table, segments)) continue; - const sequences = literalSequences(segments, keySet, current); + const sequences = literalSequences(segments, keySet, current, raw); if (sequences === null) return "unknown"; for (const seq of sequences) chain(seq, edges); } @@ -891,32 +991,57 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio const known = result.get(id); return known === undefined ? "unknown" : asSource(id, known); }; - for (const scc of sccs) { - const members = new Set(scc); - const guess = new Map(); - for (const id of scc) { - const shape = shapes.get(id); - guess.set(id, inScope(shape) ? { order: tableOf(shape) } : "unknown"); - } - const lookup = (id: string): Resolved => - members.has(id) ? asSource(id, guess.get(id)!) : external(id); - let stable = false; - for (let round = 0; round < scc.length + 3 && !stable; round++) { - stable = true; + const resolveAll = (): void => { + result.clear(); + late.clear(); + for (const scc of sccs) { + const members = new Set(scc); + const guess = new Map(); for (const id of scc) { - const next = resolveOne(id, lookup); - const prev = guess.get(id)!; - const same = - next === "unknown" - ? prev === "unknown" - : prev !== "unknown" && prev.order.join("\0") === next.order.join("\0"); - if (!same) { - stable = false; - guess.set(id, next); + const shape = shapes.get(id); + guess.set(id, inScope(shape) ? { order: tableOf(shape) } : "unknown"); + } + const lookup = (id: string): Resolved => + members.has(id) ? asSource(id, guess.get(id)!) : external(id); + const rawLookup = (id: string): Resolved => + members.has(id) ? guess.get(id)! : (result.get(id) ?? "unknown"); + let stable = false; + for (let round = 0; round < scc.length + 3 && !stable; round++) { + stable = true; + for (const id of scc) { + const next = resolveOne(id, lookup, rawLookup); + const prev = guess.get(id)!; + const same = + next === "unknown" + ? prev === "unknown" + : prev !== "unknown" && prev.order.join("\0") === next.order.join("\0"); + if (!same) { + stable = false; + guess.set(id, next); + } } } + for (const id of scc) result.set(id, stable ? guess.get(id)! : "unknown"); } - for (const id of scc) result.set(id, stable ? guess.get(id)! : "unknown"); + }; + // Ordered spreads join once their source resolves; each one can let its + // own shape resolve, which a later spread of it needs. + resolveAll(); + for (let grew = true; grew;) { + grew = false; + for (const [segments, sites] of leadingSpreads) { + const first = segments[0]; + if (first?.kind !== "spread" || orderedSpreads.has(segments)) continue; + // A self copy resolves with its own shape. + const target = sites[0]!.literal.type; + const source = result.get(first.shapeId) ?? "unknown"; + const self = target.kind === "record" && target.shapeId === first.shapeId; + if ((source === "unknown" && !self) || !ordersSpread(segments, sites)) continue; + orderedSpreads.add(segments); + if (target.kind === "record") factsOf(target.shapeId).runtime = true; + grew = true; + } + if (grew) resolveAll(); } // ── observation ───────────────────────────────────────────────────── @@ -925,6 +1050,19 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio for (const id of notes.observed) shapesIn({ kind: "record", shapeId: id }, observed, observedSeen); for (const root of observedRoots) shapesIn(root, observed, observedSeen); + // An ordered spread's copies of values without a source take the source's + // order from its table, which must then hold the resolved order. + const orderedTarget = (sites: readonly LeadingSpread[]): string | null => { + const target = sites[0]!.literal.type; + return target.kind === "record" && (result.get(target.shapeId) ?? "unknown") !== "unknown" + ? target.shapeId + : null; + }; + for (const [segments, sites] of leadingSpreads) { + const first = segments[0]; + if (orderedSpreads.has(segments) && first?.kind === "spread" && orderedTarget(sites) !== null) + observed.add(first.shapeId); + } // ── apply ─────────────────────────────────────────────────────────── for (const [id, resolved] of result) { @@ -937,6 +1075,46 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio notes.changed.add(id); } + // Ordered spreads carry their source's order over (see above). + let orderedResults = 0; + const rewritten = new Set(); + for (const [segments, sites] of leadingSpreads) { + const first = segments[0]; + if (!orderedSpreads.has(segments) || first?.kind !== "spread" || first.value === undefined) + continue; + // A shape that did not resolve keeps today's table for every value. + if (orderedTarget(sites) === null) continue; + for (const { wrapper, literal, fn } of sites) { + if (rewritten.has(wrapper)) continue; + rewritten.add(wrapper); + const loc = literal.loc; + const localId = `%orderedSpread.${orderedResults++}`; + fn.locals.push({ id: localId, name: "%orderedSpread", type: literal.type, mutable: false }); + const built: IrExpr = { kind: "varRef", localId, type: literal.type, loc }; + const source: IrExpr = { + kind: "varRef", + localId: first.value.localId, + type: { kind: "record", shapeId: first.shapeId }, + loc, + }; + wrapper.stmts.push( + { kind: "varDecl", localId, init: literal, loc }, + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "record.sourceSpread", + args: [built, source], + type: VOID, + loc, + }, + loc, + }, + ); + wrapper.result = { kind: "varRef", localId, type: literal.type, loc }; + } + } + // ── per-instance orders ───────────────────────────────────────────── // A shape whose literals write its keys in more than one order has no // one table. Each value then records which of the orders it was written diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index df30445c6..e174238e2 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4458,6 +4458,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * (inspect, Object.keys/values/entries) then answer through its live * checked view. The receiver is any such record type. Never throws. */ "record.sourceHas": { argTypes: [null], result: BOOL }, + /** `record.sourceSpread(built, value)`: the record a literal starting + * with `...value` just built keeps the order of value's kept source + * (key-order-witness.ts ordered spreads). Both are record types. Never + * throws. */ + "record.sourceSpread": { argTypes: [null, null], result: VOID }, "insp.key": { argTypes: [STRING], result: STRING }, "insp.moreItems": { argTypes: [F64], result: STRING }, "insp.end": { argTypes: [STRING, STRING, STRING, F64, BOOL, BOOL], result: STRING }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7750-key-order-javascript-spread-sources/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7750-key-order-javascript-spread-sources/main.ts.json new file mode 100644 index 000000000..19f1ba620 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7750-key-order-javascript-spread-sources/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/7750-key-order-javascript-spread-sources/lib.js", + "/tests/corpus/7750-key-order-javascript-spread-sources/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 9f7ddc527..73dad7bd3 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1055,6 +1055,12 @@ void scr_dyn_typed_ref_bind_record(ScrDyn *d, const ScrDynTypedRecordOps *ops); void scr_record_source_note(void *rec, const ScrDyn *src, const char *const *names, const size_t *lens, size_t count); bool scr_record_source_has(const void *rec); +/* A literal that starts with `...src` creates src's keys first, in src's + * order: when src kept a source, the fresh record `dst` (whose shape's + * declared names are `names`) keeps one listing the keys it declares in + * that order. Undeclared keys are not copied, as the literal copies none. */ +void scr_record_source_spread(void *dst, const void *src, const char *const *names, + const size_t *lens, size_t count); /* Object.freeze of a record a program may freeze: sets SCR_RC_FROZEN in its * rc word (immortal records are left as they are). */ void scr_record_freeze(void *record); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index a036e0dfd..35e1d7674 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -3283,6 +3283,45 @@ void scr_record_source_note(void *rec, const ScrDyn *src, const char *const *nam scr_record_source_put(&probe); } +void scr_record_source_spread(void *dst, const void *src, const char *const *names, + const size_t *lens, size_t count) { + if (!scr_record_source_count || !dst || !src) return; + ScrRecordSource *found = scr_record_source_find(src); + if (!found) return; + ScrRecordSource probe = { dst, NULL, (const char *)names, (const char *)lens, + sizeof *names, sizeof *lens, count }; + /* Nothing to keep when the declared keys come in the table's order and + * the keys src lacks would follow them in it anyway. */ + const ScrDyn *from = found->view; + bool keep = false; + size_t next = 0; + for (size_t i = 0; i < from->v.obj.len && !keep; i++) { + const ScrDynEntry *entry = &from->v.obj.entries[i]; + size_t index = scr_record_source_index(&probe, entry->key, entry->key_len); + if (index == count) continue; + keep = index != next; + next++; + } + if (!keep) return; + ScrDyn *view = scr_dyn_new_obj(); + for (size_t i = 0; i < from->v.obj.len; i++) { + const ScrDynEntry *entry = &from->v.obj.entries[i]; + if (scr_record_source_declared(&probe, entry->key, entry->key_len)) + scr_dyn_obj_set(view, entry->key, entry->key_len, scr_dyn_retain(scr_dyn_undefined())); + } + /* The keys src lacks follow in the table's order. The view lists them + * too, so an integer key among them comes first in its own-key order, as + * the literal creates it. */ + for (size_t i = 0; i < count; i++) { + if (scr_dyn_obj_get(view, names[i], lens[i])) continue; + scr_dyn_obj_set(view, names[i], lens[i], scr_dyn_retain(scr_dyn_undefined())); + } + ScrRecordSource *old = scr_record_source_find(dst); + if (old) scr_record_source_drop(old); + probe.view = view; + scr_record_source_put(&probe); +} + void scr_record_source_move(void *from, void *to) { if (!scr_record_source_count) return; ScrRecordSource *old = scr_record_source_find(to); diff --git a/tests/corpus/7750-key-order-javascript-spread-sources/lib.js b/tests/corpus/7750-key-order-javascript-spread-sources/lib.js new file mode 100644 index 000000000..c8fc0d066 --- /dev/null +++ b/tests/corpus/7750-key-order-javascript-spread-sources/lib.js @@ -0,0 +1,14 @@ +// Records JavaScript builds in its own key order, handed to TypeScript +// through typed slots: a draft from a "validator", a stored row, and a +// replacement method installed through a computed key. +export function validate(input) { + return { year: input.year, title: input.title, tags: input.tags }; +} + +export function storeRow(rows, id) { + rows.set(id, { title: "Stored", id, tags: [], year: 1999, createdAt: "t0", version: 1 }); +} + +export function replace(target, name) { + target[name] = (id) => ({ version: 7, id, year: 2001, title: "Replaced", tags: ["r"], createdAt: "t1" }); +} diff --git a/tests/corpus/7750-key-order-javascript-spread-sources/main.ts b/tests/corpus/7750-key-order-javascript-spread-sources/main.ts new file mode 100644 index 000000000..073ff6c5e --- /dev/null +++ b/tests/corpus/7750-key-order-javascript-spread-sources/main.ts @@ -0,0 +1,122 @@ +// Literals that start by spreading a record JavaScript built keep that +// record's key order, as Node does: a draft from JavaScript spread into a +// new record, a self copy of a record JavaScript stored or returned, and +// a wider view of either. Native drafts and copies keep the literal order. +import { replace, storeRow, validate } from "./lib.js"; + +interface Book { + id: string; + title: string; + year: number; + tags: readonly string[]; + createdAt: string; + version: number; +} + +type Draft = Omit; +type Patch = Partial; + +interface Availability { + copies: number; + onLoan: number; +} + +type BookView = Book & { availability: Availability }; + +class Repository { + private readonly rows = new Map(); + private sequence = 0; + + create(draft: Draft): Book { + const book: Book = { + ...draft, + tags: [...draft.tags].sort(), + id: `bk_${++this.sequence}`, + createdAt: "t" + this.sequence, + version: 1, + }; + this.rows.set(book.id, book); + return book; + } + + require(id: string): Book { + const row = this.rows.get(id); + if (row === undefined) throw new Error("missing " + id); + return row; + } + + update(id: string, patch: Patch): Book { + const current = this.require(id); + const next: Book = { ...current, ...patch, version: current.version + 1 }; + this.rows.set(id, next); + return next; + } + + view(book: Book): BookView { + return { ...book, availability: { copies: 2, onLoan: book.version } }; + } + + table(): Map { + return this.rows; + } +} + +function show(label: string, value: Book): void { + console.log(label, JSON.stringify(value)); + console.log(" keys", Object.keys(value).join()); +} + +function showView(label: string, value: BookView): void { + console.log(label, JSON.stringify(value)); + console.log(" keys", Object.keys(value).join()); +} + +const repo = new Repository(); + +// A native draft: the literal's order. +const native = repo.create({ title: "Native", year: 1990, tags: ["b", "a"] }); +show("native", native); +showView("native view", repo.view(native)); +show("native update", repo.update(native.id, { year: 1991 })); + +// A draft JavaScript built in another order. +const draft: Draft = validate({ title: "Checked", year: 2020, tags: ["z", "y"] }); +const checked = repo.create(draft); +show("checked", checked); +showView("checked view", repo.view(checked)); +show("checked update", repo.update(checked.id, { title: "Checked 2" })); +showView("checked update view", repo.view(repo.require(checked.id))); + +// A row JavaScript stored into the typed map. +storeRow(repo.table(), "js_1"); +show("stored", repo.require("js_1")); +show("stored update", repo.update("js_1", { tags: ["s"] })); +showView("stored view", repo.view(repo.require("js_1"))); + +// A replacement method returning rows JavaScript builds. +replace(repo, "require"); +show("replaced", repo.require("x")); +show("replaced update", repo.update("x", { year: 2002 })); +showView("replaced view", repo.view(repo.require("y"))); + +// The copies are independent records. +const again = repo.view(checked); +showView("again", again); +console.log(native.version, checked.version, again.availability.onLoan); + +// An integer-named key the spread source lacks comes first, as Node +// creates integer keys first whatever the literal's order. +interface Ranked { + year: number; + title: string; + tags: readonly string[]; + id: string; + "1": string; +} +function rank(source: Draft, id: string): Ranked { + return { ...source, id, "1": "gold" }; +} +for (const ranked of [rank(draft, "r1"), rank({ title: "Native", year: 1990, tags: [] }, "r2")]) { + console.log("ranked", JSON.stringify(ranked)); + console.log(" keys", Object.keys(ranked).join()); +} diff --git a/tests/harness/corpus-costs/7750-key-order-javascript-spread-sources/main.ts.json b/tests/harness/corpus-costs/7750-key-order-javascript-spread-sources/main.ts.json new file mode 100644 index 000000000..eb6dbd224 --- /dev/null +++ b/tests/harness/corpus-costs/7750-key-order-javascript-spread-sources/main.ts.json @@ -0,0 +1 @@ +{ "plain": 5300, "san": 9200 }