diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index dfeffdecb0..b5ca61f42e 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1179,8 +1179,11 @@ export class LlDyn { const struct = mangleRecordStruct(sourceId); const base = host.nullableUnions.fieldBase(sourceId); const offsets = target.fields.map((field) => { - const index = source.fields.findIndex((f) => f.name === field.name) + base; - return `${S} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${index}) to ${S})`; + const index = source.fields.findIndex((f) => f.name === field.name); + // A partial view's target field the source does not declare has + // no slot: all ones (SIZE_MAX) marks it absent (record-views.ts). + if (index < 0) return `${S} -1`; + return `${S} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${index + base}) to ${S})`; }); const offsetTable = `${table}_${k}`; this.defs.push( diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 6980c8f45f..14932f2b08 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5060,6 +5060,8 @@ export class LlEmitter { idx, (decl) => this.declare(decl), this.rcMode === undefined, + (text) => this.cstr(text), + this.shapeHost.recordViews?.partialTargets.has(shapeId) === true, ); } else { p = this.B.tmp(); diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 7599a65dee..44db911859 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -10,6 +10,7 @@ import { matchStringSelfConcat, undefinedArmTag } from "../../ir/analysis.js"; import { isRefCounted, type IrExpr, type IrStrCmpOp, type IrType } from "../../ir/ir.js"; import { mangleRecordClone, mangleRecordNew } from "../mangle.js"; import { arrNewCall, elemAccess } from "./shapes.js"; +import { recordViewTestLines } from "./record-views.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import { offsetIndex } from "./expr-bytes.js"; import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js"; @@ -1473,16 +1474,68 @@ export function emitRecordExpr( return host.emitRecordKeyGet(e); case "recordHas": { // Own-property presence: only an undefined-armed union slot can - // lack its property, by holding the ABSENT state. + // lack its property, by holding the ABSENT state, and a PARTIAL + // record view (record-views.ts) can lack a field its viewed record + // does not declare. + const fieldType = host.recordsById + .get(e.shapeId) + ?.fields.find((f) => f.name === e.field)?.type; const obj = host.emitReadReceiver(e.obj); + if (host.shapeHost.recordViews?.partialMissing(e.shapeId, e.field) !== true) { + const { ptr, type, inline } = host.recordFieldPtr(obj.name, e.shapeId, e.field); + if (type.kind !== "union" || undefinedArmTag(type, host.unionsById) < 0) + return { name: "true", type: e.type }; + const v = host.loadField(ptr, type, inline); + const absent = host.fieldAbsentTest(v, type.unionId); + const t = B.tmp(); + B.line(`${t} = xor i1 ${absent}, true`); + return { name: t, type: e.type }; + } + // A field some partial view of this shape lacks: ask the view first. + // Only a present field's slot may be looked up — a partial view's + // absent field answers no slot at all (scr_record_view_slot). + const position = host.recordsById.get(e.shapeId)!.fields.findIndex((f) => f.name === e.field); + host.declare(`declare ptr @scr_record_view_slot(ptr, ${host.sizeType})`); + const isView = B.tmp(); + const rhFrom = B.currentLabel(); + const view = B.newLabel("rh.view"); + const done = B.newLabel("rh.done"); + for (const line of recordViewTestLines(host.sizeType, obj.name, isView)) + B.line(line.trimStart()); + // Not a view: every field of the target has its slot. + B.condBr(isView, view, done, `, !prof !{!"branch_weights", i32 1, i32 1000}`); + B.startBlock(view); + const slot = B.tmp(); + B.line( + `${slot} = call ptr @scr_record_view_slot(ptr ${obj.name}, ${host.sizeType} ${position})`, + ); + const hasSlot = B.tmp(); + B.line(`${hasSlot} = icmp ne ptr ${slot}, null`); + B.br(done); + B.startBlock(done); + const hasView = B.tmp(); + B.line(`${hasView} = phi i1 [ true, %${rhFrom} ], [ ${hasSlot}, %${view} ]`); + if (fieldType === undefined || fieldType.kind !== "union") + return { name: hasView, type: e.type }; + const unionId = fieldType.unionId; + if (undefinedArmTag(fieldType, host.unionsById) < 0) return { name: hasView, type: e.type }; + // The field is also undefined-armed: a present slot still answers by + // its ABSENT state, a field the view lacks by the view. + const check = B.newLabel("rh.present"); + const join = B.newLabel("rh.join"); + const from = B.currentLabel(); + B.condBr(hasView, check, join); + B.startBlock(check); const { ptr, type, inline } = host.recordFieldPtr(obj.name, e.shapeId, e.field); - if (type.kind !== "union" || undefinedArmTag(type, host.unionsById) < 0) - return { name: "true", type: e.type }; const v = host.loadField(ptr, type, inline); - const absent = host.fieldAbsentTest(v, type.unionId); - const t = B.tmp(); - B.line(`${t} = xor i1 ${absent}, true`); - return { name: t, type: e.type }; + const absent = host.fieldAbsentTest(v, unionId); + const present = B.tmp(); + B.line(`${present} = xor i1 ${absent}, true`); + B.br(join); + B.startBlock(join); + const answer = B.tmp(); + B.line(`${answer} = phi i1 [ false, %${from} ], [ ${present}, %${check} ]`); + return { name: answer, type: e.type }; } case "fieldAbsent": return { name: host.absentInstanceRef(e.unionId), type: e.type }; diff --git a/packages/compiler/src/backend/llvm/record-views.test.ts b/packages/compiler/src/backend/llvm/record-views.test.ts index 02a82d873d..0cfec7b1e8 100644 --- a/packages/compiler/src/backend/llvm/record-views.test.ts +++ b/packages/compiler/src/backend/llvm/record-views.test.ts @@ -3,8 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; import { compile, deserializeModule } from "../../index.js"; -import type { IrModule } from "../../ir/ir.js"; +import { DYN } from "../../ir/ir.js"; +import type { IrExpr, IrModule } from "../../ir/ir.js"; import { computeTraced } from "../cycle-analysis.js"; +import { everyStmtList } from "../../ir/traverse.js"; +import { emitLlvmModule } from "./emitter.js"; import { planRecordViews } from "./record-views.js"; /** The views planned for `source`'s module, as target field names → source @@ -128,3 +131,98 @@ ${conversions} Object.freeze(wide);`); expect(views).toEqual(new Map()); }); + +/** `source`'s module shaped the way the frontend lowers a partial + * assertion (C2): the checked cast of the source record literal carries + * `partialView` over a `dynFrom{liveRef}` of that literal. */ +async function partialModule( + source: string, + sourceFields: string, + targetFields: string, +): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-record-views-")); + try { + const entry = join(dir, "main.ts"), + outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + const mod: IrModule = deserializeModule(await readFile(outPath, "utf8")); + const idOf = (fields: string): string => + (mod.records ?? []).find((shape) => shape.fields.map((f) => f.name).join(",") === fields)!.id; + const exprs: IrExpr[] = []; + for (const fn of mod.functions) + everyStmtList(fn.body, { + stmt: () => true, + expr: (e) => { + exprs.push(e); + return true; + }, + }); + const lit = exprs.find( + (e) => + e.kind === "recordLit" && e.type.kind === "record" && e.type.shapeId === idOf(sourceFields), + ); + const check = exprs.find( + (e) => + e.kind === "dynCheck" && e.type.kind === "record" && e.type.shapeId === idOf(targetFields), + ); + if (!lit || !check || check.kind !== "dynCheck") throw new Error("shapes not found"); + check.partialView = true; + check.value = { kind: "dynFrom", liveRef: true, value: lit, type: DYN, loc: check.loc }; + return mod; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("a partial assertion plans a partial view with an absent-field sentinel", async () => { + const mod = await partialModule( + ` +type Wide = { a: number; b: string }; +type Narrow = { a: number; z: boolean }; +const wide: Wide = { a: 1, b: "x" }; +const o: unknown = wide; +const v = o as Narrow; +console.log(v.a); +`, + "a,b", + "a,z", + ); + const idOf = (fields: string): string => + (mod.records ?? []).find((shape) => shape.fields.map((f) => f.name).join(",") === fields)!.id; + const target = idOf("a,z"); + const source = idOf("a,b"); + const views = planRecordViews(mod, { traced: () => false, excluded: () => false }); + expect(views.targets.has(target)).toBe(true); + expect(views.sources(target)).toEqual([source]); + expect(views.partial(target, source)).toBe(true); + expect(views.partialTargets.has(target)).toBe(true); + expect(views.partialMissing(target, "z")).toBe(true); + expect(views.partialMissing(target, "a")).toBe(false); + // The offset table marks the target field the source does not declare + // with the all-ones sentinel. + const llvm = emitLlvmModule(mod); + expect(llvm).toMatch(/@sc_rv_\d+_0 = internal constant \[2 x i64\] \[i64 ptrtoint .*, i64 -1\]/); +}); + +test("a full source keeps an ordinary view, not a partial one", async () => { + const mod = await partialModule( + ` +type Wide = { a: number; b: string }; +type Narrow = { a: number }; +const wide: Wide = { a: 1, b: "x" }; +const o: unknown = wide; +const v = o as Narrow; +console.log(v.a); +`, + "a,b", + "a", + ); + const idOf = (fields: string): string => + (mod.records ?? []).find((shape) => shape.fields.map((f) => f.name).join(",") === fields)!.id; + const views = planRecordViews(mod, { traced: () => false, excluded: () => false }); + expect(views.sources(idOf("a"))).toEqual([idOf("a,b")]); + expect(views.partial(idOf("a"), idOf("a,b"))).toBe(false); + expect(views.partialTargets.size).toBe(0); +}); diff --git a/packages/compiler/src/backend/llvm/record-views.ts b/packages/compiler/src/backend/llvm/record-views.ts index 5574d1ddec..f505556b70 100644 --- a/packages/compiler/src/backend/llvm/record-views.ts +++ b/packages/compiler/src/backend/llvm/record-views.ts @@ -15,7 +15,15 @@ * checked conversion can produce (the closure of dynCheck targets), and * some other record type whose values reach untyped code as live capsules * (the closure of live dynFrom types) has all of B's fields. A program - * without checked conversions has none, so its code is unchanged. */ + * without checked conversions has none, so its code is unchanged. + * + * A PARTIAL view (`dynCheck.partialView`) pairs a record literal's shape A + * with the wider record B it was asserted to, though A lacks some of B's + * fields. The view is a B value whose record-source entry names A, with + * the sentinel SIZE_MAX for every B field A does not declare: a slot lookup + * answers NULL there (scr_record_view_slot), so a presence test answers false + * and an address lookup stops the program. The frontend's checked reads of + * those fields throw their TypeError first. */ import { isDirentShape, KEY_ORDER_FIELD, @@ -34,9 +42,28 @@ export interface RecordViews { readonly targets: ReadonlySet; /** The record shapes whose capsules convert to a view of `target`. */ sources(target: string): readonly string[]; + /** Whether `target` may be a PARTIAL view of `source`: some of its + * fields are absent, because `source` does not declare them (a partial + * record literal asserted to a wider type). */ + partial(target: string, source: string): boolean; + /** Shape ids a partial record assertion may produce a view of. */ + readonly partialTargets: ReadonlySet; + /** Whether some partial source of view target `target` lacks `field`. */ + partialMissing(target: string, field: string): boolean; } -export const NO_RECORD_VIEWS: RecordViews = { targets: new Set(), sources: () => [] }; +/** The trap of an internal reader of a field a partial view's viewed record + * lacks (a width copy, say): a source-level read of the field threw first. */ +export const PARTIAL_VIEW_ABSENT = + "scriptc: a value asserted as a wider record has no property at this field (copy it explicitly)\n"; + +export const NO_RECORD_VIEWS: RecordViews = { + targets: new Set(), + sources: () => [], + partial: () => false, + partialTargets: new Set(), + partialMissing: () => false, +}; interface ViewOptions { readonly traced: (shapeId: string) => boolean; @@ -155,16 +182,30 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView }; const checked: IrType[] = []; const boxed: IrType[] = []; + // (target B, source A) of a partial record assertion's checked cast. + const partialPairs: { target: string; source: string }[] = []; for (const fn of mod.functions) everyStmtList(fn.body, { stmt: () => true, expr: (e) => { - if (e.kind === "dynCheck") checked.push(e.type); - else if (e.kind === "dynFrom" && e.liveRef === true) boxed.push(e.value.type); + if (e.kind === "dynCheck") { + checked.push(e.type); + if ( + e.partialView === true && + e.type.kind === "record" && + e.value.kind === "dynFrom" && + e.value.value.type.kind === "record" + ) + partialPairs.push({ + target: e.type.shapeId, + source: e.value.value.type.shapeId, + }); + } else if (e.kind === "dynFrom" && e.liveRef === true) boxed.push(e.value.type); return true; }, }); - if (checked.length === 0 || boxed.length === 0) return NO_RECORD_VIEWS; + if ((checked.length === 0 || boxed.length === 0) && partialPairs.length === 0) + return NO_RECORD_VIEWS; const reachable = closure(checked); const live = closure(boxed); if (reachable.size === 0) return NO_RECORD_VIEWS; @@ -182,6 +223,24 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView } if (found.length > 0) sources.set(id, found); } + // Partial pairs join the plan before the cycle analysis, which owns their + // view's capsule reference like any other. A pair whose source has every + // target field is an ordinary full view and is already planned above. + const partials = new Map>(); + for (const pair of partialPairs) { + const target = shapes.get(pair.target); + const source = shapes.get(pair.source); + if (!target || !source || !plainShape(target) || !plainShape(source)) continue; + if (target.freezable === true || source.freezable === true) continue; + if (options.traced(pair.target) || options.excluded(pair.target)) continue; + if (covers(source, target)) continue; + const list = sources.get(pair.target) ?? []; + if (!list.includes(pair.source)) list.push(pair.source); + sources.set(pair.target, list); + const set = partials.get(pair.target); + if (set) set.add(pair.source); + else partials.set(pair.target, new Set([pair.source])); + } // A view's record-source entry owns the source's capsule, an edge the // cycle analysis does not see. A view stays off every cycle only when its // source cannot hold a value of the target type, such as the view itself, @@ -199,11 +258,34 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView changed = true; if (kept.length > 0) sources.set(id, kept); else sources.delete(id); + const partial = partials.get(id); + if (!partial) continue; + for (const source of [...partial]) if (!kept.includes(source)) partial.delete(source); + if (partial.size === 0) partials.delete(id); } } if (sources.size === 0) return NO_RECORD_VIEWS; const targets = new Set(sources.keys()); - return { targets, sources: (target) => sources.get(target) ?? [] }; + // The target fields some partial source lacks: their presence test is a + // runtime view probe, and their slot lookup traps. + const missing = new Map>(); + for (const [id, list] of partials) { + const absent = new Set(); + const target = shapes.get(id); + for (const source of list) { + const declared = new Set((shapes.get(source)?.fields ?? []).map((field) => field.name)); + for (const field of target?.fields ?? []) + if (!declared.has(field.name)) absent.add(field.name); + } + missing.set(id, absent); + } + return { + targets, + sources: (target) => sources.get(target) ?? [], + partial: (target, source) => partials.get(target)?.has(source) === true, + partialTargets: new Set(partials.keys()), + partialMissing: (target, field) => missing.get(target)?.has(field) === true, + }; } /** Lines setting `dest` (i1) to whether the record `obj` of a view target @@ -228,7 +310,12 @@ export function recordViewTestLines(sizeType: "i32" | "i64", obj: string, dest: * `obj` of view-target shape `struct`: the slot itself, or the viewed * record's slot when `obj` is a view. Optimized builds test the mark * inline and look the slot up out of line; unoptimized builds, which give - * every value a frame slot, make the address one call. */ + * every value a frame slot, make the address one call. + * + * `partial` names a shape a PARTIAL view may present: its viewed record can + * lack the field, and the slot lookup answers NULL there (scr_record_view_slot), + * so the address stops with `scr_trap` and `cstr(PARTIAL_VIEW_ABSENT)`. The + * message is the program's own constant; the runtime carries none. */ export function recordViewFieldPtr( B: BlockBuilder, sizeType: "i32" | "i64", @@ -238,13 +325,25 @@ export function recordViewFieldPtr( position: number, declare: (decl: string) => void, outOfLine: boolean, + cstr: (text: string) => string, + partial = false, ): string { if (outOfLine) { - declare(`declare ptr @scr_record_view_field(ptr, ${sizeType}, ${sizeType})`); + const field = "scr_record_view_field"; + declare(`declare ptr @${field}(ptr, ${sizeType}, ${sizeType})`); const ptr = B.tmp(); B.line( - `${ptr} = call ptr @scr_record_view_field(ptr ${obj}, ${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${slotIndex}) to ${sizeType}), ${sizeType} ${position})`, + `${ptr} = call ptr @${field}(ptr ${obj}, ${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${slotIndex}) to ${sizeType}), ${sizeType} ${position})`, ); + if (!partial) return ptr; + const absent = B.tmp(); + B.line(`${absent} = icmp eq ptr ${ptr}, null`); + const trap = B.newLabel("rv.absent"); + const join = B.newLabel("rv.present"); + B.condBr(absent, trap, join, `, !prof !{!"branch_weights", i32 1, i32 1000}`); + B.startBlock(trap); + absentTrapLines(B, cstr, declare); + B.startBlock(join); return ptr; } const own = B.tmp(); @@ -260,9 +359,34 @@ export function recordViewFieldPtr( declare(`declare ptr @scr_record_view_slot(ptr, ${sizeType})`); const viewed = B.tmp(); B.line(`${viewed} = call ptr @scr_record_view_slot(ptr ${obj}, ${sizeType} ${position})`); + let viewFrom = view; + if (partial) { + const absent = B.tmp(); + B.line(`${absent} = icmp eq ptr ${viewed}, null`); + const trap = B.newLabel("rv.absent"); + const present = B.newLabel("rv.present"); + B.condBr(absent, trap, present); + B.startBlock(trap); + absentTrapLines(B, cstr, declare); + viewFrom = present; + B.startBlock(present); + } B.br(join); B.startBlock(join); const ptr = B.tmp(); - B.line(`${ptr} = phi ptr [ ${own}, %${from} ], [ ${viewed}, %${view} ]`); + B.line(`${ptr} = phi ptr [ ${own}, %${from} ], [ ${viewed}, %${viewFrom} ]`); return ptr; } + +/** The block of an address lookup of a field a partial view lacks: the + * program stops with its own message (PARTIAL_VIEW_ABSENT) and does not + * return. */ +export function absentTrapLines( + B: BlockBuilder, + cstr: (text: string) => string, + declare: (decl: string) => void, +): void { + declare(`declare void @scr_trap(ptr)`); + B.line(`call void @scr_trap(ptr ${cstr(PARTIAL_VIEW_ABSENT)})`); + B.terminate("unreachable"); +} diff --git a/packages/compiler/src/backend/llvm/shapes.test.ts b/packages/compiler/src/backend/llvm/shapes.test.ts index 4503b5437f..3b8e50dba3 100644 --- a/packages/compiler/src/backend/llvm/shapes.test.ts +++ b/packages/compiler/src/backend/llvm/shapes.test.ts @@ -28,6 +28,10 @@ function declarationHost( declare(decl) { declarations.push(decl); }, + cstr(text) { + declarations.push(`@cstr ${text}`); + return "@sc_cstr"; + }, needOom() {}, mapOps: (operands) => (operands.every((op) => op === "null") ? "null" : "@sc_map_ops"), sizeType: pointerBits === 32 ? "i32" : "i64", diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 87983c8b96..fc179f4d6d 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -73,7 +73,7 @@ import { disposeFieldLines, plainRefType, } from "./arenas.js"; -import { recordViewTestLines, type RecordViews } from "./record-views.js"; +import { PARTIAL_VIEW_ABSENT, recordViewTestLines, type RecordViews } from "./record-views.js"; import { recordMayKeepSource } from "./record-sources.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -109,6 +109,9 @@ export interface ShapeHost { /** Byte offset from an object pointer back to ScrCycHdr.buf. */ readonly cycleBufOffset: number; declare(decl: string): void; + /** The module constant for a string (LlEmitter.cstr), for a trap message + * an emitted accessor passes to the runtime. */ + cstr(text: string): string; /** Inline the small-object allocator's fast paths (llvm/alloc.ts). */ readonly inlineAlloc?: boolean; /** Record and class fields that store a scalar union's tag and bits @@ -2450,15 +2453,29 @@ export function emitRecordShapes( const inline = host.inlineUnions.record(field.type); const fieldTy = llFieldType(field.type, inline); if (host.recordViews?.targets.has(shape.id) === true) { - // A view's fields live in the viewed record (record-views.ts). + // A view's fields live in the viewed record (record-views.ts); a + // shape a partial view may present answers NULL for a field the + // viewed record lacks, so the copy stops with the program's own + // message (a source-level read threw first). + const partial = host.recordViews.partialTargets.has(shape.id); host.declare(`declare ptr @scr_record_view_slot(ptr, ${host.sizeType})`); + const fromView = partial + ? [ + ` %sn${i} = icmp eq ptr %sw${i}, null`, + ` br i1 %sn${i}, label %sv${i}.absent, label %sv${i}.join`, + `sv${i}.absent:`, + ` call void @scr_trap(ptr ${host.cstr(PARTIAL_VIEW_ABSENT)})`, + ` unreachable`, + ] + : [` br label %sv${i}.join`]; + if (partial) host.declare(`declare void @scr_trap(ptr)`); clone.push( ` %so${i} = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${index}`, ...recordViewTestLines(host.sizeType, "%src", `%sr${i}v`), ` br i1 %sr${i}v, label %sv${i}.view, label %sv${i}.join`, `sv${i}.view:`, ` %sw${i} = call ptr @scr_record_view_slot(ptr %src, ${host.sizeType} ${i})`, - ` br label %sv${i}.join`, + ...fromView, `sv${i}.join:`, ` %sp${i} = phi ptr [ %so${i}, %${i === 0 ? "entry" : `sv${i - 1}.join`} ], [ %sw${i}, %sv${i}.view ]`, ` %sv${i} = load ${fieldTy}, ptr %sp${i}`, diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts index f475f073bb..3f79e6541b 100644 --- a/packages/compiler/src/backend/llvm/walkers.ts +++ b/packages/compiler/src/backend/llvm/walkers.ts @@ -293,6 +293,8 @@ export class LlWalkers { index - base, (decl) => this.host.declare(decl), this.host.rcMode === undefined, + (text) => this.host.cstr(text), + this.host.recordViews?.partialTargets.has(shapeId) === true, ); } else { p = B.tmp(); diff --git a/packages/compiler/src/ir/fields.generated.ts b/packages/compiler/src/ir/fields.generated.ts index 86c6a15e5d..1e8a193c1f 100644 --- a/packages/compiler/src/ir/fields.generated.ts +++ b/packages/compiler/src/ir/fields.generated.ts @@ -2115,10 +2115,11 @@ export type IrFieldsCurrent = [ Unread["spreads"]>, readonly unknown[]>[number]>, object>, "arg" | "what">>, Unread["spreads"]>, readonly unknown[]>[number]>, object>["arg"]>>, Unread, "kind" | "callee" | "receiver" | "calleeName" | "calleeNameValue" | "args" | "spreads" | "type" | "loc">>, - Unread, "kind" | "value" | "preserveRefs" | "adopt" | "exact" | "inferredSite" | "origin" | "type" | "loc">>, + Unread, "kind" | "value" | "preserveRefs" | "adopt" | "exact" | "partialView" | "inferredSite" | "origin" | "type" | "loc">>, Unread["preserveRefs"]>>, Unread["adopt"]>>, Unread["exact"]>>, + Unread["partialView"]>>, Unread["origin"]>>, Unread, "kind" | "value" | "spelling" | "firstProp" | "type" | "loc">>, Unread, "kind" | "value" | "fnName" | "fnLength" | "liveRef" | "type" | "loc">>, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 7200da6ddd..2c211b3866 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -4098,6 +4098,13 @@ export type IrExpr = /** `exact`: the value is known to be a capsule this program boxed from * exactly the class `type` names (a `dyn.typedRefKeyIs` test guards the * expression), so the check is one load of the capsule's instance. */ + /** `partialView`: `value` is `dynFrom{liveRef}` of a record whose fields + * are a SUBSET of `type`'s, with equal types — a partial record literal + * asserted to a wider record type (`{ truncated: true } as + * TruncationResult`). The conversion answers a partial record view + * (record-views.ts): a `type` value whose fields the source lacks are + * absent, so presence tests answer false and reads of them throw the + * catchable TypeError. */ /** `adopt`: the declaration-typed boundary's cast (decl-projection.ts). * The conversion preserves identity: a source object or array that * something else still references (reference count above one) is @@ -4116,6 +4123,7 @@ export type IrExpr = preserveRefs?: true; adopt?: true; exact?: true; + partialView?: true; /** Shape inference's overlay: this conversion checks an * analysis-proven fact (" :"); a failure is a * compiler analysis bug and reports itself as one. */ diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 995603bcd2..3c32915dbe 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -6295,6 +6295,37 @@ function validateFunction( ) { err(`dynCheck against non-JSON-representable type ${e.type.kind}`, e.loc); } + if (e.partialView === true) { + // A partial record assertion (record-views.ts): the operand is a + // live record whose fields are a SUBSET of the target's, with + // equal types, so the view lacks only properties the record + // never had. + if ( + e.type.kind !== "record" || + e.value.kind !== "dynFrom" || + e.value.liveRef !== true || + e.value.value.type.kind !== "record" + ) { + err(`partialView dynCheck needs a live-record operand and a record target`, e.loc); + break; + } + const target = records.get(e.type.shapeId); + const source = records.get(e.value.value.type.shapeId); + if (!target || !source) { + err(`partialView dynCheck names an unknown record shape`, e.loc); + break; + } + for (const field of source.fields) { + const found = target.fields.find((f) => f.name === field.name); + if (!found || typeKey(found.type) !== typeKey(field.type)) { + err( + `partialView source field "${field.name}" is not a target field with an equal type`, + e.loc, + ); + break; + } + } + } break; } default: { diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 3439305800..1d2c880219 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1342,6 +1342,10 @@ typedef struct { size_t key_len; const size_t *offsets; } ScrRecordViewSource; +/* The slot of field `position` of a view, or NULL for a field the viewed + * record does not declare (a PARTIAL view holds SIZE_MAX; record-views.ts). + * A field address of a shape some partial view may present tests the answer + * and traps with the program's message. */ void *scr_record_view_slot(void *view, size_t position); void *scr_record_view_field(void *value, size_t offset, size_t position); void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t count, diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 2ecc99d9cb..14eab651b6 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4404,10 +4404,17 @@ void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t return view; } -/* The slot of field `position` of a view (emitted view guards, cold). */ +/* The slot of field `position` of a view (emitted view guards, cold), or + * NULL when the view cannot have it: a PARTIAL view (a record literal + * asserted to a wider record type) holds SIZE_MAX for every field its + * viewed record does not declare. The guard that gets NULL stops with + * scr_trap and the program's own message; a presence test asks for the same + * slot and answers by it. */ void *scr_record_view_slot(void *view, size_t position) { ScrRecordSource *entry = scr_record_source_slot(view); - return (char *)entry->viewed + entry->offsets[position]; + size_t offset = entry->offsets[position]; + if (offset == SIZE_MAX) return NULL; + return (char *)entry->viewed + offset; } /* The address of field `position` of a value of a view target type, whose