Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 63 additions & 10 deletions packages/compiler/src/frontend/lowering/array-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type IrType,
isIntegerBytesElem,
type SrcLoc,
STRING,
UNDEFINED_T,
typeEquals,
typeKey,
Expand Down Expand Up @@ -84,29 +85,54 @@ export function arrayValueRead(
};
}

/** Read a stabilized union of native arrays without copying either the
* receiver or its elements. Every arm contributes its missing-value case. */
/** Read one position of a stabilized union of native arrays and tuples
* (`const [a, b] = re.exec(s) || ["", ""]`) without copying the receiver
* or its elements. Every array arm contributes its missing-value case; a
* tuple arm answers its field, or undefined past its end. Tuple arms need
* a constant position. With `stringChars` (the string iterator's code
* points as a fresh string[]), a string arm (`re.exec(s) || ""`) answers
* its code point at the position, or undefined past its end. */
export function unionArrayValueRead(
lowerer: Lowerer,
value: IrExpr,
index: IrExpr,
loc: SrcLoc,
stringChars?: (text: IrExpr) => IrExpr,
): IrExpr | null {
if (value.type.kind !== "union") return null;
const unionId = value.type.unionId;
const arms = lowerer.unions.get(unionId)?.arms;
if (!arms?.length) return null;
const position = index.kind === "numLit" ? String(index.value) : null;
// A tuple arm's answer at the position: its field, or undefined past
// its end. Null when the arm is neither an array nor a tuple.
const tupleField = (arm: IrType): { name: string; type: IrType } | "past" | null => {
if (arm.kind !== "record" || position === null) return null;
const shape = lowerer.shapes.get(arm.shapeId);
if (!shape?.tuple) return null;
return shape.fields.find((f) => f.name === position) ?? "past";
};
// Keep the collection's IrType union layout and narrow each element at
// its use; an inferred array predicate changes the collection's ABI.
for (const arm of arms) if (arm.kind !== "array") return null;
const stringArm = (arm: IrType): boolean => arm.kind === "string" && stringChars !== undefined;
for (const arm of arms)
if (arm.kind !== "array" && !stringArm(arm) && tupleField(arm) === null) return null;
const answers = new Map<string, IrType>();
for (const arm of arms) {
if (arm.kind !== "array") return null;
const answer = arrayValueType(lowerer, arm.elem);
const field = arm.kind === "array" || stringArm(arm) ? null : tupleField(arm);
const answer =
arm.kind === "array"
? arrayValueType(lowerer, arm.elem)
: stringArm(arm)
? arrayValueType(lowerer, STRING)
: field === "past"
? UNDEFINED_T
: field!.type;
const parts = answer.kind === "union" ? lowerer.unions.get(answer.unionId)!.arms : [answer];
for (const part of parts) answers.set(typeKey(part), part);
}
const joined = [...answers.values()].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
if (joined.length === 1 && joined[0]!.kind === "undefinedT") return null;
if (
joined.length > 1 &&
(!unionContainerArmsOk(joined) ||
Expand All @@ -118,12 +144,39 @@ export function unionArrayValueRead(
let result: IrExpr | null = null;
for (let tag = arms.length - 1; tag >= 0; tag--) {
const arm = arms[tag]!;
if (arm.kind !== "array") return null;
const receiver: IrExpr = { kind: "unionNarrow", unionId, tag, value, type: arm, loc };
const read = lowerer.coerceToExpected(
arrayValueRead(lowerer, receiver, index, arm.elem, loc),
type,
);
let read: IrExpr;
if (arm.kind === "array") {
read = lowerer.coerceToExpected(
arrayValueRead(lowerer, receiver, index, arm.elem, loc),
type,
);
} else if (stringArm(arm)) {
read = lowerer.coerceToExpected(
arrayValueRead(lowerer, stringChars!(receiver), index, STRING, loc),
type,
);
} else {
const field = tupleField(arm);
if (field === null) return null;
if (field === "past") {
const missing = lowerer.wrappedUndefined(type, loc);
if (!missing) return null;
read = missing;
} else {
read = lowerer.coerceToExpected(
{
kind: "recordGet",
obj: receiver,
shapeId: (arm as IrType & { kind: "record" }).shapeId,
field: field.name,
type: field.type,
loc,
},
type,
);
}
}
result =
result === null
? read
Expand Down
113 changes: 107 additions & 6 deletions packages/compiler/src/frontend/lowering/lower-comptime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { InternalCompilerError } from "../../errors.js";
* literals — records/arrays/unions included, subject to comptimeBakeable. */
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { type IrExpr, type IrType } from "../../ir/ir.js";
import { isUnitType, type IrExpr, type IrType } from "../../ir/ir.js";
import { comptimeFailedDiag } from "../../diagnostics/diagnostic.js";
import { locOf } from "../program.js";
import { PoisonError } from "./lowerer.js";
Expand Down Expand Up @@ -76,7 +76,7 @@ export function lowerComptime(lowerer: Lowerer, expr: ts.CallExpression): IrExpr
"SC1090",
expr,
`baking comptime results of type '${lowerer.fmt(target)}' into the binary ` +
`(only number, string, boolean, arrays, and records lower to literals)`,
`(number, string, boolean, and arrays, records and unions of those lower to literals; a record or union type reachable from itself does not)`,
);
}
// 5. The host executes the extracted callback under a finite budget.
Expand Down Expand Up @@ -110,20 +110,104 @@ function comptimeFailureDetail(error: unknown): string {

/** True when a comptime result of this type can be written as a literal
* expression: number/string/boolean, arrays and record shapes of those,
* nested. Closures, class instances, unions, promises, dyn, and void
* cannot be baked. */
* and unions of those with null or undefined (the mixed rows of a JSON
* table: `(string | number)[][]`), nested. Closures, class instances,
* promises, dyn, void, and recursive types (a record or union reachable
* from itself, directly or through arrays) cannot be baked. */
export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean {
return bakeableWithin(lowerer, t, new Set());
}

/** comptimeBakeable below the records and unions in `open` (keyed
* `record:<shapeId>` and `union:<unionId>`), whose members are being
* checked: reaching one of them again means the type is recursive. */
function bakeableWithin(lowerer: Lowerer, t: IrType, open: Set<string>): boolean {
switch (t.kind) {
case "f64":
case "string":
case "bool":
return true;
case "array":
return lowerer.comptimeBakeable(t.elem);
return bakeableWithin(lowerer, t.elem, open);
case "record": {
const shape = lowerer.shapes.get(t.shapeId);
const key = `record:${t.shapeId}`;
if (!shape || open.has(key)) return false;
open.add(key);
const bakeable = shape.fields.every((f) => bakeableWithin(lowerer, f.type, open));
open.delete(key);
return bakeable;
}
case "union": {
const arms = lowerer.unions.get(t.unionId)?.arms;
const key = `union:${t.unionId}`;
if (arms === undefined || open.has(key)) return false;
open.add(key);
const bakeable = arms.every(
(arm) => isUnitType(arm) || (arm.kind !== "union" && bakeableWithin(lowerer, arm, open)),
);
Comment thread
vercel[bot] marked this conversation as resolved.
open.delete(key);
return bakeable;
}
default:
return false;
}
}

/** The tag of the arm of union `unionId` that `value` bakes into, or -1.
* A record value whose discriminant field holds one of the union's
* discriminant values takes that case's arm, since arms with overlapping
* shapes can both fit it. Any other value takes the first arm it fits, in
* the union's arm order: a JSON value fits exactly one scalar arm, and
* array or record arms are told apart by their contents. */
function comptimeUnionArm(lowerer: Lowerer, unionId: string, value: unknown): number {
const def = lowerer.unions.get(unionId);
if (!def) return -1;
const discriminant = def.discriminant;
if (
discriminant !== undefined &&
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
const key = (value as Record<string, unknown>)[discriminant.field];
const named = discriminant.cases.find((c) => c.values.some((v) => v === key));
if (named !== undefined)
return comptimeFits(lowerer, value, def.arms[named.tag]!) ? named.tag : -1;
}
return def.arms.findIndex((arm) => comptimeFits(lowerer, value, arm));
}

/** Whether `value` bakes into the bakeable type `t` without a mismatch:
* the arm choice of a union literal. Records are exact, as in
* comptimeValueToIr; an absent field reads as undefined. */
function comptimeFits(lowerer: Lowerer, value: unknown, t: IrType): boolean {
switch (t.kind) {
case "f64":
return typeof value === "number" && Number.isFinite(value);
case "string":
return typeof value === "string";
case "bool":
return typeof value === "boolean";
case "nullT":
return value === null;
case "undefinedT":
return value === undefined;
case "array":
return Array.isArray(value) && value.every((el) => comptimeFits(lowerer, el, t.elem));
case "record": {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const shape = lowerer.shapes.get(t.shapeId);
return !!shape && shape.fields.every((f) => lowerer.comptimeBakeable(f.type));
if (!shape) return false;
const obj = value as Record<string, unknown>;
const declared = new Set(shape.fields.map((f) => f.name));
return (
Object.keys(obj).every((key) => declared.has(key)) &&
shape.fields.every((f) => comptimeFits(lowerer, obj[f.name], f.type))
);
}
case "union":
return comptimeUnionArm(lowerer, t.unionId, value) >= 0;
default:
return false;
}
Expand Down Expand Up @@ -265,6 +349,23 @@ export function comptimeValueToIr(
}));
return { kind: "recordLit", fields, type: expected, loc };
}
case "union": {
const arms = lowerer.unions.get(expected.unionId)!.arms; // bakeable-checked
const tag = comptimeUnionArm(lowerer, expected.unionId, value);
if (tag < 0) fail(describeComptimeValue(value));
const arm = arms[tag]!;
const payload: IrExpr = isUnitType(arm)
? { kind: "unitLit", unit: arm.kind === "nullT" ? "null" : "undefined", type: arm, loc }
: lowerer.comptimeValueToIr(value, arm, path, blame);
return {
kind: "unionWrap",
unionId: expected.unionId,
tag,
value: payload,
type: expected,
loc,
};
}
default:
// comptimeBakeable already fenced these off.
throw new InternalCompilerError(`lowerer bug: unbakeable comptime target '${expected.kind}'`);
Expand Down
18 changes: 14 additions & 4 deletions packages/compiler/src/frontend/lowering/lower-stmts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2379,20 +2379,30 @@ export function lowerBindingPattern(
lowerer.fenceStaticHeadersIteration(pattern);
if (
srcType.kind === "union" &&
lowerer.unions.get(srcType.unionId)?.arms.every((arm) => arm.kind === "array")
lowerer.unions
.get(srcType.unionId)
?.arms.every(
(arm) =>
arm.kind === "array" ||
arm.kind === "string" ||
(arm.kind === "record" && lowerer.shapes.get(arm.shapeId)?.tuple === true),
) &&
lowerer.unions.get(srcType.unionId)!.arms.some((arm) => arm.kind !== "string")
) {
for (let i = 0; i < pattern.elements.nodes.length; i++) {
const el = pattern.elements.nodes[i]!;
if (ts.isOmittedExpression(el) || el.name === undefined) continue;
if (el.dotDotDotToken)
lowerer.unsupported("SC1031", el, "rest elements over a union of array layouts");
lowerer.unsupported("SC1031", el, "rest elements over a union of array or tuple layouts");
const loc = locOf(el);
let value = unionArrayValueRead(lowerer, srcRef(), numLit(i, loc), loc);
let value = unionArrayValueRead(lowerer, srcRef(), numLit(i, loc), loc, (text) =>
strCharsCall(lowerer, text, loc),
);
if (!value)
lowerer.unsupported(
"SC1031",
el,
"array union elements without a common native representation",
"array or tuple union elements without a common native representation",
);
if (el.initializer) value = applyBindingDefault(lowerer, el, value);
lowerer.bindPatternTarget(el.name, value, isLet, out);
Expand Down
3 changes: 3 additions & 0 deletions packages/compiler/src/frontend/type-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4411,6 +4411,9 @@ function unionDiscriminant(
): IrUnionDiscriminant | undefined {
if (arms.filter((arm) => arm.kind === "record").length < 2 || parts.length < 2) return undefined;
const { checker } = ctx;
// Tuple shapes keep positions only: their checker `length` literal has
// no slot to compare (discriminantField refuses tuple shapes too).
if (parts.some((part) => ctx.shapes.get(part.mapped.shapeId)?.tuple)) return undefined;
for (const candidate of checker.getPropertiesOfType(parts[0]!.source)) {
if (candidate.name.startsWith("__@")) continue;
const byTag = new Map<number, (string | number | boolean)[]>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6410-json-mixed-scalar-tables/main.ts"
],
"diags": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6411-union-array-tuple-destructuring.ts"
],
"diags": []
}
17 changes: 17 additions & 0 deletions tests/corpus/1052-comptime-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,20 @@ const meta = comptime(() => {
return { kind: "meta", tags, count: tags.length };
});
console.log(meta.kind, meta.tags.join("|"), meta.count);

// A discriminated union whose arms both fit a "labeled" value without a
// label: the discriminant picks the arm.
type Item = { kind: "plain"; v: number } | { kind: "labeled"; v: number; label?: string };
const items: Item[] = comptime(
() =>
[
{ kind: "plain", v: 1 },
{ kind: "labeled", v: 2 },
{ kind: "labeled", v: 3, label: "x" },
] as Item[],
);
for (const item of items) {
if (item.kind === "labeled") console.log("labeled", item.v, item.label);
else console.log("plain", item.v);
}
console.log(JSON.stringify(items));
1 change: 1 addition & 0 deletions tests/corpus/6410-json-mixed-scalar-tables/extra.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[["x", 1, "two", 3], [4, "five", null], ["six"], [null]]
26 changes: 26 additions & 0 deletions tests/corpus/6410-json-mixed-scalar-tables/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// JSON tables whose rows mix strings and numbers, the shape of a
// character-set table (`[["8140", 12288, 65292], ...]`) and of a
// code-point range map (`[[[0, 44], "disallowed"], [[65, 65], "mapped",
// [97]], ...]`). A package requires them at load or inside a function, and
// an ES module imports one; each row keeps its strings and numbers.
import { lookup, rowCount } from "code-table";
import { mapChars, status } from "range-map";
import extra from "./extra.json" with { type: "json" };

console.log(lookup("8150"), lookup("a1"), lookup("zzzz"), rowCount());
const mapped = mapChars("AB-c9");
console.log(mapped.text, mapped.error);
console.log(JSON.stringify(mapChars("a,b")), JSON.stringify(mapChars("{")));
console.log(status(10), status(46), status(66), status(1000));

for (const row of extra) {
const head = row[0];
const rest = row.slice(1);
console.log(
typeof head,
head,
rest.length,
rest.map((v) => (v === null ? "null" : typeof v === "number" ? v + 1 : v.toUpperCase())).join(","),
);
}
console.log(JSON.stringify(extra));

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading