mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-27 06:43:19 +08:00
Fix recursive JSON unions and primitive constructors as array callbacks (#1782)
This commit is contained in:
@@ -6,7 +6,9 @@ import { varRef } from "../../../ir/build.js";
|
||||
import { typeKey } from "../../type-mapper.js";
|
||||
import {
|
||||
BOOL,
|
||||
DYN,
|
||||
F64,
|
||||
STRING,
|
||||
type IrExpr,
|
||||
type IrType,
|
||||
type SrcLoc,
|
||||
@@ -69,6 +71,20 @@ export function lowerArrayCallback(
|
||||
if (valueT.kind === "union") lowerer.requireTruthyUnion(valueT.unionId, argNode);
|
||||
return { fnArg: truthyFilterCallback(lowerer, valueT, locOf(argNode)), arity: 1 };
|
||||
}
|
||||
// The ambient `String` (`values.map(String)`): String(value) converts its
|
||||
// first argument as a direct call does, whatever the element type; the
|
||||
// constructor's own value form takes a string.
|
||||
if (
|
||||
ts.isIdentifier(argNode) &&
|
||||
argNode.text === "String" &&
|
||||
!isJsSourceFile(ts.getSourceFileOfNode(argNode)) &&
|
||||
lowerer.isStdlibSymbol(lowerer.resolveValueSymbol(argNode) ?? undefined)
|
||||
) {
|
||||
const valueT = lead[0];
|
||||
if (valueT === undefined || valueT.kind === "void")
|
||||
lowerer.badType(argNode, lowerer.typeOf(argNode));
|
||||
return { fnArg: stringConversionCallback(lowerer, valueT, argNode), arity: 1 };
|
||||
}
|
||||
const full = [...lead, F64, arrT];
|
||||
// A DYN-receiver HOF's callback (`parsed.flatMap((value) => ...)`):
|
||||
// the contextual signature types the unannotated param `any` (the
|
||||
@@ -274,6 +290,49 @@ function runtimeOptionalPairVariant(lowerer: Lowerer, expected: IrType, mapped:
|
||||
return lowerer.isRuntimeOptionalRecordVariant(strip(expected), strip(mapped));
|
||||
}
|
||||
|
||||
/** `String` as a one-parameter callback over `valueT`: the conversion a
|
||||
* direct `String(value)` call lowers to (the runtime's String() for unknown
|
||||
* values and unions holding a symbol, ToString otherwise), interned per
|
||||
* value type. */
|
||||
function stringConversionCallback(
|
||||
lowerer: Lowerer,
|
||||
valueT: IrType,
|
||||
node: ts.Expression,
|
||||
): IrExpr & { type: IrType & { kind: "func" } } {
|
||||
const loc = locOf(node);
|
||||
const fnT: IrType & { kind: "func" } = { kind: "func", params: [valueT], ret: STRING };
|
||||
const key = `mapString:${typeKey(valueT)}`;
|
||||
let name = lowerer.arrHofHelpers.get(key);
|
||||
if (!name) {
|
||||
name = `%arr.mapString.${lowerer.arrHofHelpers.size}`;
|
||||
lowerer.arrHofHelpers.set(key, name);
|
||||
const value = varRef("v.0", valueT, loc);
|
||||
const runtimeString =
|
||||
valueT.kind === "dyn" ||
|
||||
(valueT.kind === "union" &&
|
||||
lowerer.unions.get(valueT.unionId)?.arms.some((arm) => arm.kind === "symbol") === true &&
|
||||
lowerer.dynConvertible(valueT));
|
||||
const converted: IrExpr = runtimeString
|
||||
? {
|
||||
kind: "libCall",
|
||||
fn: "dyn.stringConstructor",
|
||||
args: [lowerer.coerceToExpected(value, DYN)],
|
||||
type: STRING,
|
||||
loc,
|
||||
}
|
||||
: lowerer.ensureString(value, node);
|
||||
lowerer.liftedFns.push({
|
||||
name,
|
||||
params: [{ localId: "v.0", name: "v", type: valueT }],
|
||||
returnType: STRING,
|
||||
locals: [{ id: "v.0", name: "v", type: valueT, mutable: false }],
|
||||
body: [{ kind: "return", value: converted, loc }],
|
||||
loc,
|
||||
});
|
||||
}
|
||||
return { kind: "closure", fnName: name, captures: [], type: fnT, loc };
|
||||
}
|
||||
|
||||
/** A native closure for the ambient Boolean callback. Reifying the
|
||||
* JavaScript constructor itself is unnecessary; Array#filter only observes
|
||||
* its one-argument ToBoolean result. */
|
||||
|
||||
@@ -184,6 +184,21 @@ export function lowerArrayLiteral(
|
||||
if (arms.length === 1) mapped = arms[0]!;
|
||||
}
|
||||
}
|
||||
// A NON-EMPTY literal whose own type is an array, under a union with
|
||||
// exactly one array arm and no tuple arm (`[1, { n: true }]` in a
|
||||
// `Json[]` arm of a recursive `Json`): the checker types each element
|
||||
// from the arm, so its object literals lower as the arm's elements,
|
||||
// and the literal's own element type (`number | { n: boolean }`)
|
||||
// cannot hold them. Build it as the arm; its value only flows into the
|
||||
// union slot.
|
||||
if (ctxUnion && expr.elements.nodes.length > 0 && mapped?.kind === "array") {
|
||||
const def = lowerer.unions.get(ctxUnion.unionId);
|
||||
const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? [];
|
||||
const tupleArms =
|
||||
def?.arms.filter((a) => a.kind === "record" && !!lowerer.shapes.get(a.shapeId)?.tuple) ??
|
||||
[];
|
||||
if (arrayArms.length === 1 && tupleArms.length === 0) mapped = arrayArms[0]!;
|
||||
}
|
||||
}
|
||||
// A Date's toJSON() element keeps its null in a literal with no declared
|
||||
// destination: the lib types say string.
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DYN,
|
||||
F64,
|
||||
type IrExpr,
|
||||
type IrLocal,
|
||||
type IrType,
|
||||
STRING,
|
||||
type SrcLoc,
|
||||
@@ -226,7 +227,10 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
|
||||
if (use?.kind === "element") {
|
||||
const key = runtimeOptionalElementKey(use.access.argumentExpression);
|
||||
if (key === null) {
|
||||
return varRef(local.id, local.type, loc);
|
||||
return (
|
||||
runtimeOptionalKeyedRecord(lowerer, expr, local, loc) ??
|
||||
varRef(local.id, local.type, loc)
|
||||
);
|
||||
}
|
||||
return runtimeOptionalReceiverRead(lowerer, expr, local, key, loc);
|
||||
}
|
||||
@@ -938,10 +942,43 @@ function moduleFileName(lowerer: Lowerer, sf: ts.SourceFile): string {
|
||||
: sf.fileName;
|
||||
}
|
||||
|
||||
/** Object.keys/Object.entries require an object receiver, but their first
|
||||
* argument is not syntactically a member receiver. Recognize that one
|
||||
* standard-library position so a runtime-optional loop element can be
|
||||
* checked against the checker-narrowed record arm before builtin lowering. */
|
||||
/** A computed-key access (`value[key]` after `typeof value === "object"`)
|
||||
* of a runtime-optional binding the checker narrowed to one record arm of a
|
||||
* wider union. The binding re-tags to that record or undefined, so the keyed
|
||||
* read or write evaluates its operands and then throws Node's TypeError for
|
||||
* an undefined receiver. The other arms, which the checker excluded, fail
|
||||
* the checked re-tag. Null when the binding stores only the record and
|
||||
* undefined, or the narrowed record is not one of its arms. */
|
||||
function runtimeOptionalKeyedRecord(
|
||||
lowerer: Lowerer,
|
||||
expr: ts.Identifier,
|
||||
local: IrLocal,
|
||||
loc: SrcLoc,
|
||||
): IrExpr | null {
|
||||
if (local.type.kind !== "union") return null;
|
||||
const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr));
|
||||
if (narrowed?.kind !== "record") return null;
|
||||
const fromId = local.type.unionId;
|
||||
const arms = lowerer.unions.get(fromId)?.arms;
|
||||
const recordTag = lowerer.armTag(fromId, narrowed);
|
||||
const undefTag = lowerer.armTag(fromId, UNDEFINED_T);
|
||||
if (!arms || arms.length <= 2 || recordTag < 0 || undefTag < 0) return null;
|
||||
const target = lowerer.withUndefinedArm(narrowed);
|
||||
if (target.kind !== "union") return null;
|
||||
const trappable = new Set<number>();
|
||||
for (let tag = 0; tag < arms.length; tag++) {
|
||||
if (tag !== recordTag && tag !== undefTag) trappable.add(tag);
|
||||
}
|
||||
const helper = lowerer.unionRetagHelper(fromId, target.unionId, loc, trappable);
|
||||
if (!helper) return null;
|
||||
return lowerer.checkedNarrowCall(helper, varRef(local.id, local.type, loc), target, loc);
|
||||
}
|
||||
|
||||
/** Object.keys, Object.values and Object.entries require an object
|
||||
* receiver, but their first argument is not syntactically a member
|
||||
* receiver. Recognize that one standard-library position so a
|
||||
* runtime-optional loop element can be checked against the checker-narrowed
|
||||
* record arm before builtin lowering. */
|
||||
function runtimeOptionalObjectWalkerArg(lowerer: Lowerer, expr: ts.Expression): boolean {
|
||||
let arg = expr;
|
||||
while (
|
||||
@@ -962,7 +999,9 @@ function runtimeOptionalObjectWalkerArg(lowerer: Lowerer, expr: ts.Expression):
|
||||
return false;
|
||||
const access = call.expression;
|
||||
return (
|
||||
(access.name.text === "keys" || access.name.text === "entries") &&
|
||||
(access.name.text === "keys" ||
|
||||
access.name.text === "values" ||
|
||||
access.name.text === "entries") &&
|
||||
lowerer.isStdlibMember(access)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ import {
|
||||
stringWrapperToString,
|
||||
symbolFieldInfo,
|
||||
templateRawTextOf,
|
||||
primitiveCtorClosure,
|
||||
} from "./lower-exprs.js";
|
||||
import { isSafeToDiscard } from "./expressions/evaluation-safety.js";
|
||||
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
|
||||
@@ -4343,7 +4344,25 @@ export function lowerDynDispatchMethodCall(
|
||||
}
|
||||
}
|
||||
}
|
||||
// `String`, `Number` or `Boolean` as the callback of an array method
|
||||
// (`values.map(String)` over an unknown array): the method calls it with
|
||||
// each element, whatever its type, and cannot observe which function
|
||||
// value it received, so it gets the constructor's form that converts any
|
||||
// argument rather than the string-input form typed code stores.
|
||||
const arrayCallbackReceiver =
|
||||
ARRAY_CALLBACK_METHODS.has(method) &&
|
||||
lowerer.checker.isArrayType(lowerer.typeOf(access.expression));
|
||||
const args = call.arguments.nodes.map((arg, i) => {
|
||||
const primitive =
|
||||
i === 0 && arrayCallbackReceiver && ts.isIdentifier(arg)
|
||||
? stdlibGlobalNameOf(lowerer, arg)
|
||||
: null;
|
||||
if (primitive === "String" || primitive === "Number" || primitive === "Boolean")
|
||||
return lowerer.coerceInto(
|
||||
arg,
|
||||
primitiveCtorClosure(lowerer, primitive, locOf(arg), true),
|
||||
DYN,
|
||||
);
|
||||
if (i === 0 && predicate) return lowerer.coerceInto(arg, predicate, DYN);
|
||||
const undefinedArg = lowerStaticallyUndefinedArgument(lowerer, arg);
|
||||
return undefinedArg
|
||||
@@ -4361,6 +4380,21 @@ export function lowerDynDispatchMethodCall(
|
||||
};
|
||||
}
|
||||
|
||||
/** Array.prototype methods whose first argument is a callback they call
|
||||
* with the elements. */
|
||||
const ARRAY_CALLBACK_METHODS = new Set([
|
||||
"map",
|
||||
"filter",
|
||||
"forEach",
|
||||
"some",
|
||||
"every",
|
||||
"find",
|
||||
"findIndex",
|
||||
"findLast",
|
||||
"findLastIndex",
|
||||
"flatMap",
|
||||
]);
|
||||
|
||||
/** Every String.prototype method name a typed or dyn string lowering
|
||||
* answers. */
|
||||
function isStringMethodName(name: string): boolean {
|
||||
|
||||
@@ -3057,8 +3057,10 @@ export function lowerFieldCompound(
|
||||
* returns the zero-capture closure over it. Typed sources use the mapped
|
||||
* `(value: string) => primitive` signature; a recognized JavaScript
|
||||
* concise-arrow selector (`() => String`, the picocolors fallback) takes
|
||||
* dyn so its ordinary coercion signature survives inference residue. The
|
||||
* dyn bodies run exact ToString/ToNumber/ToBoolean, including object hooks.
|
||||
* dyn so its ordinary coercion signature survives inference residue, and a
|
||||
* boxed constructor value takes dyn too (coerceToExpected). The dyn bodies
|
||||
* run what the constructors do for any argument: String() (a symbol's
|
||||
* description form included), ToNumber and ToBoolean, with object hooks.
|
||||
* Interning makes every reference under one ABI the SAME immortal closure,
|
||||
* so `opt.type === String` compares like JS function identity. */
|
||||
export function primitiveCtorClosure(
|
||||
@@ -3081,7 +3083,7 @@ export function primitiveCtorClosure(
|
||||
? name === "BigInt"
|
||||
? { kind: "libCall", fn: "dyn.bigintConstructor", args: [v], type: BIGINT_T, loc }
|
||||
: name === "String"
|
||||
? { kind: "libCall", fn: "dyn.toStringCoerce", args: [v], type: STRING, loc }
|
||||
? { kind: "libCall", fn: "dyn.stringConstructor", args: [v], type: STRING, loc }
|
||||
: name === "Number"
|
||||
? { kind: "libCall", fn: "dyn.numberConstructor", args: [v], type: F64, loc }
|
||||
: { kind: "dynTest", test: "truthy", value: v, type: BOOL, loc }
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6826-map-primitive-constructors.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6827-recursive-union-nested-literals.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6828-recursive-union-object-walkers.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// The String, Number and Boolean constructors passed as callbacks: over
|
||||
// unknown arrays they convert any element, over typed arrays String converts
|
||||
// each element as String(value) does, and the boxed values keep identity.
|
||||
const values: unknown[] = [1, "b", true, null, undefined, Symbol("s"), [1, 2], { a: 1 }, 2n];
|
||||
console.log(values.map(String).join("|"));
|
||||
console.log(values.slice(0, 5).map(Number), values.map(Boolean));
|
||||
|
||||
function joinAll(...parts: unknown[]): string {
|
||||
return parts.map(String).join(",");
|
||||
}
|
||||
console.log(joinAll(1, "a", false, null));
|
||||
|
||||
const numbers = [1, 2.5, -0, NaN, Infinity];
|
||||
console.log(numbers.map(String));
|
||||
const mixed: (number | string | boolean)[] = [1, "x", false];
|
||||
console.log(mixed.map(String));
|
||||
const symbols: (symbol | string)[] = [Symbol("q"), "r", Symbol()];
|
||||
console.log(symbols.map(String));
|
||||
console.log([10n, -3n].map(String));
|
||||
console.log(["1", " 2 ", "x", ""].map(Number), ["", "0", "a"].map(Boolean));
|
||||
|
||||
const boxed: unknown = String;
|
||||
const table: { [name: string]: unknown } = { s: String, n: Number, b: Boolean };
|
||||
console.log(boxed === String, table.n === Number, table.b === String, typeof boxed);
|
||||
console.log(values.filter(Boolean).length, values.some(Boolean), numbers.every(Boolean));
|
||||
@@ -0,0 +1,29 @@
|
||||
// A recursive JSON-like union with an array arm and an index-signature
|
||||
// record arm: nested literals mix records into arrays and arrays into
|
||||
// records at every depth.
|
||||
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
||||
|
||||
function show(value: Json): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
const config: Json = { list: [1, "two", null, { nested: [true, { deeper: [] }] }], flag: false };
|
||||
console.log(show(config));
|
||||
console.log(show({ list: [{ n: true }] }));
|
||||
console.log(show([1, { k: null }, [{ a: [2] }]]));
|
||||
console.log(show({ a: { b: [{ c: "x" }, 3] } }));
|
||||
|
||||
const rows: Json[] = [{ id: 1, tags: ["a", { t: 2 }] }, [null, { x: [] }]];
|
||||
console.log(rows.map(show).join(" | "));
|
||||
|
||||
let value: Json = [];
|
||||
value = { replaced: [1, { again: [true] }] };
|
||||
console.log(show(value));
|
||||
|
||||
const doc: { [key: string]: Json } = { items: [{ name: "x", parts: [1, { p: "q" }] }] };
|
||||
console.log(show(doc));
|
||||
const items = doc["items"];
|
||||
if (Array.isArray(items)) {
|
||||
items.push({ added: [9] });
|
||||
console.log(items.length, show(items));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Recursive walkers over a JSON-like union: the array branch calls the
|
||||
// walker for each element, and the record branch reads the narrowed record
|
||||
// with Object.values, Object.keys and Object.entries.
|
||||
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
||||
|
||||
function depth(value: Json): number {
|
||||
if (Array.isArray(value)) return 1 + Math.max(0, ...value.map(depth));
|
||||
if (value !== null && typeof value === "object") return 1 + Math.max(0, ...Object.values(value).map(depth));
|
||||
return 0;
|
||||
}
|
||||
|
||||
function count(value: Json): number {
|
||||
let total = 1;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) total += count(item);
|
||||
} else if (value !== null && typeof value === "object") {
|
||||
for (const item of Object.values(value)) total += count(item);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function paths(value: Json, prefix: string): string[] {
|
||||
if (Array.isArray(value)) return value.flatMap((item, index) => paths(item, `${prefix}[${index}]`));
|
||||
if (value !== null && typeof value === "object") {
|
||||
const out: string[] = [];
|
||||
for (const key of Object.keys(value)) out.push(...paths(value[key]!, `${prefix}.${key}`));
|
||||
for (const [key, item] of Object.entries(value)) if (item === null) out.push(`${prefix}.${key}=null`);
|
||||
return out;
|
||||
}
|
||||
return [`${prefix}=${String(value)}`];
|
||||
}
|
||||
|
||||
const doc: Json = JSON.parse('{"a":[1,{"b":[true,null]}],"c":{"d":"x"},"e":[]}');
|
||||
console.log(depth(doc), count(doc));
|
||||
console.log(paths(doc, "$").join(" "));
|
||||
console.log(depth(3), count([[[]]]), paths([], "$"));
|
||||
|
||||
// A computed-key read or write in a branch that an array hole reaches throws
|
||||
// Node's TypeError after the key (and, for a write, the value) evaluates.
|
||||
type Bag = number | string | number[] | { [key: string]: number };
|
||||
const keys: string[] = [];
|
||||
function keyOf(k: string): string {
|
||||
keys.push(k);
|
||||
return k;
|
||||
}
|
||||
function isBag(value: Bag): value is { [key: string]: number } {
|
||||
return !Array.isArray(value) && typeof value !== "string" && typeof value !== "number";
|
||||
}
|
||||
function bump(value: Bag, k: string): string {
|
||||
if (isBag(value)) {
|
||||
value[keyOf(k)] = (value[keyOf(k)] ?? 0) + 1;
|
||||
return String(value[keyOf(k)]);
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
function put(value: Bag, k: string): string {
|
||||
if (isBag(value)) {
|
||||
value[keyOf(k)] = keys.length;
|
||||
return "put";
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
function peek(value: Bag, k: string): string {
|
||||
if (isBag(value)) return typeof value[keyOf(k)];
|
||||
return "-";
|
||||
}
|
||||
const bags: Bag[] = [{ a: 1 }, "s", [1]];
|
||||
bags.length = 4;
|
||||
for (const which of ["bump", "put", "peek"]) {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < bags.length; i++) {
|
||||
try {
|
||||
const k = "a" + which.length;
|
||||
if (which === "bump") out.push(bump(bags[i], k));
|
||||
else if (which === "put") out.push(put(bags[i], k));
|
||||
else out.push(peek(bags[i], k));
|
||||
} catch (e) {
|
||||
out.push(e instanceof TypeError ? e.message : "other");
|
||||
}
|
||||
}
|
||||
console.log(out.join(" | "));
|
||||
}
|
||||
console.log(keys.join(","), JSON.stringify(bags));
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 3700, "san": 3500 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 3500, "san": 3400 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 4200, "san": 5800 }
|
||||
Reference in New Issue
Block a user