diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 245d1b676..6f01cee19 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -2011,6 +2011,47 @@ function formatOArg(lowerer: Lowerer, node: ts.Expression, depth: number, loc: S return inspectExpr(lowerer, value.type, value, numLit(0, loc), numLit(depth, loc), loc); } +/** %d and %i over a checked-dynamic argument, as Node's formatWithOptions + * reads it: a bigint prints as `${n}n`, a symbol as NaN, and any other + * value as `number` (Number(arg) for %d, parseInt(String(arg)) for %i) + * formatted. The argument evaluates once. */ +function formatDynInteger( + lowerer: Lowerer, + value: IrExpr, + number: (held: IrExpr) => IrExpr, + loc: SrcLoc, +): IrExpr { + const held = lowerer.declareHiddenLocal("%formatArg", DYN); + const ref = (): IrExpr => varRef(held.id, DYN, loc); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: held.id, init: value, loc }], + result: { + kind: "ternary", + cond: { kind: "dynTest", test: "bigint", value: ref(), type: BOOL, loc }, + then: { + kind: "strConcat", + left: { kind: "libCall", fn: "dyn.toStringCoerce", args: [ref()], type: STRING, loc }, + right: strLit("n", loc), + type: STRING, + loc, + }, + else_: { + kind: "ternary", + cond: { kind: "dynTest", test: "symbol", value: ref(), type: BOOL, loc }, + then: strLit("NaN", loc), + else_: { kind: "libCall", fn: "insp.f64", args: [number(ref())], type: STRING, loc }, + type: STRING, + loc, + }, + type: STRING, + loc, + }, + type: STRING, + loc, + }; +} + /** %d and %i over the argument types Node formats without a number * conversion: a bigint prints as `${n}n` (formatBigInt) and a symbol as NaN; * null is Number(null) (0) under %d and parseInt("null") (NaN) under %i, and @@ -2141,19 +2182,20 @@ export function lowerFormatCall( }; } // A checked-dynamic argument (`console.log('... actual %d.', - // context.actual)` — test/common's exit report): VALIDATE it as a - // number (dynCheck) and format. A non-number dyn value throws the - // catchable TypeError where Node would print its ToNumber (NaN - // for objects) — it throws instead of returning a wrong result - // (SEMANTICS.md). + // context.actual)` — test/common's exit report): Number(arg). if (value.type.kind === "dyn") { - return { - kind: "libCall", - fn: "insp.f64", - args: [{ kind: "dynCheck", value, type: F64, loc }], - type: STRING, + return formatDynInteger( + lowerer, + value, + (held) => ({ + kind: "libCall", + fn: "dyn.numberConstructor", + args: [held], + type: F64, + loc, + }), loc, - }; + ); } lowerer.noLowering( `util.format %d of '${lowerer.fmt(value.type)}' values`, @@ -2185,6 +2227,24 @@ export function lowerFormatCall( }; return { kind: "libCall", fn: "insp.f64", args: [parsed], type: STRING, loc }; } + // parseInt(String(arg)) over a checked-dynamic argument. + if (value.type.kind === "dyn") { + return formatDynInteger( + lowerer, + value, + (held) => ({ + kind: "libCall", + fn: "num.parseInt", + args: [ + { kind: "libCall", fn: "dyn.toStringCoerce", args: [held], type: STRING, loc }, + numLit(0, loc), + ], + type: F64, + loc, + }), + loc, + ); + } lowerer.noLowering(`util.format %i of '${lowerer.fmt(value.type)}' values`, node); break; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6356-format-integer-dyn.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6356-format-integer-dyn.ts.json new file mode 100644 index 000000000..6e1082a83 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6356-format-integer-dyn.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6356-format-integer-dyn.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6356-format-integer-dyn.ts b/tests/corpus/6356-format-integer-dyn.ts new file mode 100644 index 000000000..a4bd53ab0 --- /dev/null +++ b/tests/corpus/6356-format-integer-dyn.ts @@ -0,0 +1,9 @@ +// util.format's %d and %i over checked-dynamic arguments, as Node reads +// them: a bigint prints as `${n}n`, a symbol as NaN, and any other value +// as Number(arg) (%d) or parseInt(String(arg)) (%i). +import { format } from "node:util"; +const values: unknown[] = [{}, [5], [1, 2], "12px", " 0x1f", "", null, undefined, true, 3.9, -0, 10n, Symbol("s"), new Date(0), { valueOf: () => 7 }, NaN, "1e3"]; +for (const v of values) console.log(format("%d|%i", v, v)); +console.log(format("%d and %i", 2n ** 70n, -5n)); +const report: unknown = { actual: 3 }; +console.log("... actual %d.", (report as { actual: unknown }).actual); diff --git a/tests/harness/corpus-costs/6356-format-integer-dyn.ts.json b/tests/harness/corpus-costs/6356-format-integer-dyn.ts.json new file mode 100644 index 000000000..8bcdf4a18 --- /dev/null +++ b/tests/harness/corpus-costs/6356-format-integer-dyn.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 2000 }