Format checked-dynamic arguments under %d and %i as Node does (#1488)

This commit is contained in:
Chris Tate
2026-10-20 00:25:46 -05:00
committed by GitHub
4 changed files with 87 additions and 11 deletions
@@ -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;
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6356-format-integer-dyn.ts"
],
"diags": []
}
+9
View File
@@ -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);
@@ -0,0 +1 @@
{ "plain": 2000, "san": 2000 }