diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index b02cf44f3..365393326 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1036,6 +1036,7 @@ export const LIB_FN_SYMS: Record = { "insp.dynS": "scr_insp_dyn_s", "insp.format": "scr_insp_format_pack", "insp.formatPrimitives": "scr_insp_format_primitives", + "insp.formatList": "scr_insp_format_list", "insp.formatWithOptions": "scr_insp_format_with_options", "insp.inspect": "scr_insp_inspect", "insp.customEnabled": "scr_insp_custom_enabled", diff --git a/packages/compiler/src/frontend/lowering/lower-console.ts b/packages/compiler/src/frontend/lowering/lower-console.ts index db4d39e70..21540cf47 100644 --- a/packages/compiler/src/frontend/lowering/lower-console.ts +++ b/packages/compiler/src/frontend/lowering/lower-console.ts @@ -21,6 +21,7 @@ import { lowerInspectValue, lowerRuntimeFormat, runtimeFormatString, + formatStringConverts, } from "./lower-inspect.js"; import { streamSidesOf } from "./lower-stream.js"; @@ -372,7 +373,7 @@ export function lowerConsoleOutput( return consoleWrite(stdout, [lowerRuntimeFormat(lowerer, nodes, loc)], loc); const runtime = nodes.length > 1 && !isStringLiteralLike(first) ? runtimeFormatString(lowerer, first!) : null; - if (runtime === "other") + if (runtime === "other" && !formatStringConverts(lowerer, first!)) return consoleWrite(stdout, [lowerRuntimeFormat(lowerer, nodes, loc)], loc); if (runtime !== null) { const held = heldFormatArgs(lowerer, nodes, loc); diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index dd950a2a2..9afe12ed4 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -35,6 +35,7 @@ import { canConvertToDyn, canDynCheckTo, funcOf, + arrayOf, CLASS_FIELD_ATTRIBUTES_PREFIX, classDynViewSupported, DYN_CLASS_PROPERTIES, @@ -3205,7 +3206,8 @@ export function lowerFormatCall( if (!firstIsLiteral) { const runtime = argNodes.length > 1 ? runtimeFormatString(lowerer, first) : null; - if (runtime === "other") return lowerRuntimeFormat(lowerer, argNodes, loc); + if (runtime === "other" && !formatStringConverts(lowerer, first)) + return lowerRuntimeFormat(lowerer, argNodes, loc); if (runtime !== null) { // A string that substitutes formats at run time; any other string // joins with the remaining arguments, as below. @@ -3576,6 +3578,14 @@ function runtimeFenceValue( }; } +/** Whether the held-argument form can test a format string whose type + * only may hold a string: a union that converts to a checked-dynamic value, + * or a value whose checker type does not map (its lowered type decides). */ +export function formatStringConverts(lowerer: Lowerer, first: ts.Expression): boolean { + const t = lowerer.mapTypeOf(lowerer.typeOf(first)); + return t === null || t.kind === "dyn" || lowerer.dynConvertible(t); +} + /** How a non-literal format string is known: "string" and "dyn" values * test for a substitution at run time, "other" types (unions with a string * arm) format at run time, and null types never hold a string. */ @@ -3673,14 +3683,10 @@ export function heldFormatArgs( "util.format of a void call result (call it as its own statement, then pass undefined)", ); } - if (value.type.kind !== "dyn" && !lowerer.dynConvertible(value.type)) { - // JavaScript keeps the space-joined branch, which printed these - // values before, and refuses only a string that substitutes. - if (!isJsSourceFile(ts.getSourceFileOfNode(node))) { - lowerer.noLowering(unconvertibleSurface(value.type), node, UNCONVERTIBLE_HINT); - } + // The space-joined branch prints these values as before; only a string + // that substitutes refuses them, where it runs. + if (value.type.kind !== "dyn" && !lowerer.dynConvertible(value.type)) unconvertible ??= { node, type: value.type }; - } // Unit values have no local: their effects run here and a literal // stands in. const k = value.type.kind; @@ -3694,16 +3700,20 @@ export function heldFormatArgs( return { node, ref: () => varRef(held.id, value.type, loc) }; }); const first = args[0]!.ref(); + // A lowered value that is neither a string nor convertible holds no + // string, so it never substitutes. const specifies: IrExpr = first.type.kind === "string" ? { kind: "libCall", fn: "insp.formatSpecifies", args: [first], type: BOOL, loc } - : { - kind: "libCall", - fn: "insp.formatSpecifiesDyn", - args: [lowerer.coerceToExpected(first, DYN)], - type: BOOL, - loc, - }; + : first.type.kind === "dyn" || lowerer.dynConvertible(first.type) + ? { + kind: "libCall", + fn: "insp.formatSpecifiesDyn", + args: [lowerer.coerceToExpected(first, DYN)], + type: BOOL, + loc, + } + : { kind: "boolLit", value: false, type: BOOL, loc }; const format = (): IrExpr => { if (unconvertible !== null) { const fenced = unconvertible; @@ -3725,14 +3735,18 @@ export function heldFormatArgs( t.kind === "undefinedT" || t.kind === "nullT" || (t.kind === "union" && (lowerer.unions.get(t.unionId)?.arms.every(primitive) ?? false)); + // A native list of the checked values: converting into it is a tier + // boundary, so the statement stays static. return { kind: "libCall", - fn: args.every((arg) => primitive(arg.ref().type)) ? "insp.formatPrimitives" : "insp.format", + fn: args.every((arg) => primitive(arg.ref().type)) + ? "insp.formatPrimitives" + : "insp.formatList", args: [ { - kind: "dynArrLit", + kind: "arrayLit", elems: args.map((arg) => lowerer.coerceToExpected(arg.ref(), DYN)), - type: DYN, + type: arrayOf(DYN), loc, }, ], @@ -3748,6 +3762,11 @@ export function heldFormatArgs( if (t.kind === "bytes") return true; return inspectSupport(lowerer, t, new Set(), { recursive: false }) === null; }); + // Without a joined branch every call formats: TypeScript refuses the + // value at compile time. + const refused = unconvertible as { node: ts.Expression; type: IrType } | null; + if (!joinable && refused !== null && !isJsSourceFile(ts.getSourceFileOfNode(refused.node))) + lowerer.noLowering(unconvertibleSurface(refused.type), refused.node, UNCONVERTIBLE_HINT); return { stmts, args, specifies, format, joinable }; } diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index d676b05dd..357992a5a 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -183,6 +183,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ // The runtime formatter calls toString, valueOf and toJSON, and refuses // renderings it has no lowering for. "insp.format", + "insp.formatList", "insp.formatWithOptions", "insp.inspect", // The options object reads the caller's options (getters included), and diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 6e07e1467..c127af93c 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4376,7 +4376,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "insp.format": { argTypes: [DYN], result: STRING }, /** insp.format over arguments that are all strings, numbers, booleans, * null or undefined, without the checked-dynamic walk. */ - "insp.formatPrimitives": { argTypes: [DYN], result: STRING }, + "insp.formatPrimitives": { argTypes: [arrayOf(DYN)], result: STRING }, + "insp.formatList": { argTypes: [arrayOf(DYN)], result: STRING }, "insp.formatWithOptions": { argTypes: [DYN, DYN], result: STRING }, /** util.inspect(value, options, depth, colors) with the call's argument * count: the runtime reads the options and renders the checked-dynamic diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index ac0747276..6a30eb92b 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -407,6 +407,18 @@ const DYN_READ_ONLY = new Set([ "fs.utimesSync", "insp.dyn", "insp.dynS", + // util.format and util.inspect over checked values: the inspect walk and + // the format conversions read them; insp.registerCustom keeps a class's + // custom inspect thunk in the runtime's registry. + "insp.customIsSelf", + "insp.customResult", + "insp.format", + "insp.formatList", + "insp.formatPrimitives", + "insp.formatSpecifiesDyn", + "insp.formatWithOptions", + "insp.inspect", + "insp.registerCustom", // The dynamic-tier operators run their dyn twins (scr_js_value.c); the // conversions move a value between dyn and jsval, and js.toF64 is // dynCheck's number check. diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 2c7951103..da49804c1 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -2575,11 +2575,21 @@ static ScrStr *insp_prim_number(ScrDyn *v, int spec) { static const InspFmtOps insp_fmt_primitive_ops = {insp_prim_s, insp_prim_j, insp_prim_number, insp_prim_inspect}; -ScrStr *scr_insp_format_primitives(ScrDyn *pack) { +/* A native list of checked values (the compiled format calls) as the + * runtime's argument pack. +1. */ +static ScrDyn *insp_list_pack(ScrArr *list) { + ScrDyn *pack = scr_dyn_new_arr(); + for (uint32_t i = 0; i < list->len; i++) scr_dyn_arr_push(pack, scr_arr_get_ref(list, i)); + return pack; +} + +ScrStr *scr_insp_format_primitives(ScrArr *list) { ScrInspFormatOptions o; scr_insp_format_defaults(&o, "util.format"); - if (pack->kind != SCR_DYN_ARR) return scr_str_new("", 0); - return insp_format_loop(pack->v.arr.items, pack->v.arr.len, &o, &insp_fmt_primitive_ops); + ScrDyn *pack = insp_list_pack(list); + ScrStr *out = insp_format_loop(pack->v.arr.items, pack->v.arr.len, &o, &insp_fmt_primitive_ops); + scr_dyn_release(pack); + return out; } /* The arguments of a dyn array pack (+1 text, or NULL pending). */ @@ -2594,6 +2604,13 @@ ScrStr *scr_insp_format_pack(ScrDyn *pack) { return insp_fmt_pack(pack, &o); } +ScrStr *scr_insp_format_list(ScrArr *list) { + ScrDyn *pack = insp_list_pack(list); + ScrStr *out = scr_insp_format_pack(pack); + scr_dyn_release(pack); + return out; +} + /* Whether a format string can substitute: a '%' before its last character. * A string without one formats as its arguments joined with spaces. */ bool scr_insp_format_specifies(ScrStr *first) { diff --git a/packages/runtime/src/scr_inspect.h b/packages/runtime/src/scr_inspect.h index a95ac1fde..339fe781b 100644 --- a/packages/runtime/src/scr_inspect.h +++ b/packages/runtime/src/scr_inspect.h @@ -76,9 +76,13 @@ ScrStr *scr_insp_format(ScrDyn *const *args, size_t argc, const ScrInspFormatOpt /* util.format(...pack) and util.formatWithOptions(options, ...pack) over a * dyn array of the arguments (+1). */ ScrStr *scr_insp_format_pack(ScrDyn *pack); -/* util.format(...pack) when every argument is a string, number, boolean, +/* util.format over a native list of checked values, the compiled calls' + * held arguments (+1). */ +struct ScrArr; +ScrStr *scr_insp_format_list(struct ScrArr *list); +/* scr_insp_format_list when every argument is a string, number, boolean, * null or undefined, without the checked-dynamic walk (+1). */ -ScrStr *scr_insp_format_primitives(ScrDyn *pack); +ScrStr *scr_insp_format_primitives(struct ScrArr *list); ScrStr *scr_insp_format_with_options(ScrDyn *options, ScrDyn *pack); /* util.inspect(value, options, depth, colors) over checked-dynamic * arguments with Node's legacy-argument rules; argc is the call's