Lower Number(aString), unary + on strings, and %d over strings

- num.fromString rides both backends: the C emitter case and the LLVM declare table entry (declare double @scr_string_to_number(ptr), the C prototype exactly)
- Number(string) retires its fence; the union hint now points at narrowing (numbers, booleans, and strings lower)
- unary + on string operands and util.format %d string arguments take the same libCall
- corpus 2387 (argv-style CLI parsing), 2388 (the grammar zoo edge by edge), 2389 (+str idioms and %d) — differential-verified on both backends
This commit is contained in:
Chris Tate
2026-07-22 20:02:21 -05:00
parent 5d6abeaefe
commit e53dc344a5
10 changed files with 129 additions and 9 deletions
@@ -2426,6 +2426,8 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp {
return finish(`scr_parse_int(${arg(0)}, ${arg(1)})`);
case "num.parseFloat":
return finish(`scr_parse_float(${arg(0)})`);
case "num.fromString":
return finish(`scr_string_to_number(${arg(0)})`);
case "num.isNaN":
return finish(`(bool)isnan(${arg(0)})`);
// The URI codecs (scr_string.c). Borrow; results +1. decode
@@ -168,6 +168,7 @@ const LIB_FN_SYMS: Record<string, string> = {
"math.random": "scr_math_random",
"num.parseInt": "scr_parse_int",
"num.parseFloat": "scr_parse_float",
"num.fromString": "scr_string_to_number",
"math.round": "scr_math_round",
// decodeUriComponent is NOT here: it throws (MAY_THROW_LIB_FNS), so it
// refuses by name like the rest of the throwing tier.
@@ -2699,9 +2699,11 @@ export function lowerCall(L: Lowerer, expr: ts.CallExpression): IrExpr {
// The lib constructors-as-functions with STATIC conversion semantics:
// String(x) is exactly the template-literal ToString, Boolean(x) is
// exactly the condition ToBoolean (union arms included), Number(x) is
// ToNumber for the types where it's trivial (numbers pass through,
// booleans become 1/0) — string parsing (full ToNumber grammar) has no
// static lowering and fences with a pointer at the island parsers.
// ToNumber where it lowers exactly: numbers pass through, booleans
// become 1/0, and strings run the runtime's ECMA-exact
// StringToNumber (num.fromString — the full StringNumericLiteral
// grammar, scr_string.c). Other argument types (unions included —
// narrow first) keep the fence.
// Provenance-checked like setTimeout; zero-arg forms are the JS
// constants ("", false, 0). `new String(...)` (wrapper objects) stays
// on the SC2020 fence.
@@ -2748,11 +2750,14 @@ export function lowerCall(L: Lowerer, expr: ts.CallExpression): IrExpr {
loc,
};
}
if (arg.type.kind === "string") {
return { kind: "libCall", fn: "num.fromString", args: [arg], type: F64, loc };
}
L.noLowering(
`Number of ${L.fmt(arg.type)} values`,
argNode,
arg.type.kind === "string"
? "the full ToNumber string grammar has no static lowering — parseInt(s, 10) compiles statically; parseFloat runs with --dynamic"
arg.type.kind === "union"
? "numbers, booleans, and strings lower (the full ToNumber string grammar included) — narrow the union first"
: undefined,
);
}
@@ -5860,10 +5860,15 @@ export function lowerPrefixUnary(L: Lowerer, expr: ts.PrefixUnaryExpression): Ir
}
case ts.SyntaxKind.PlusToken: {
const operand = L.lowerExpr(expr.operand);
// Unary + is ToNumber; on an already-number operand it's identity.
// Unary + is ToNumber; on an already-number operand it's identity,
// and a STRING operand runs the runtime's ECMA-exact StringToNumber
// (num.fromString — Number(aString)'s lowering, scr_string.c).
if (operand.type.kind === "jsval") {
return { kind: "jsOp", op: "plus", args: [operand], type: JSVAL, loc };
}
if (operand.type.kind === "string") {
return { kind: "libCall", fn: "num.fromString", args: [operand], type: F64, loc };
}
if (operand.type.kind !== "f64") L.unsupported("SC1043", expr);
return operand;
}
@@ -915,10 +915,15 @@ export function lowerFormatCall(L: Lowerer, expr: ts.CallExpression, loc: SrcLoc
case 115: // %s
return formatSArg(L, node, 0, loc);
case 100: {
// %d — Number(arg) formatted: numbers as-is, booleans 1/0;
// the full ToNumber string grammar has no static lowering.
// %d — Number(arg) formatted: numbers as-is, booleans 1/0,
// strings through the runtime's ECMA-exact StringToNumber
// (num.fromString — the same lowering Number(aString) takes).
const value = L.lowerExpr(node);
if (value.type.kind === "f64") return { kind: "libCall", fn: "insp.f64", args: [value], type: STRING, loc };
if (value.type.kind === "string") {
const parsed: IrExpr = { kind: "libCall", fn: "num.fromString", args: [value], type: F64, loc };
return { kind: "libCall", fn: "insp.f64", args: [parsed], type: STRING, loc };
}
if (value.type.kind === "bool") {
return {
kind: "libCall",
@@ -942,7 +947,7 @@ export function lowerFormatCall(L: Lowerer, expr: ts.CallExpression, loc: SrcLoc
loc,
};
}
L.noLowering(`util.format %d of '${L.fmt(value.type)}' values`, node, "numbers and booleans lower; ToNumber over other types has no static lowering");
L.noLowering(`util.format %d of '${L.fmt(value.type)}' values`, node, "numbers, booleans, and strings lower; ToNumber over other types has no static lowering");
break;
}
case 105: {
+9
View File
@@ -1578,6 +1578,15 @@ export type IrLibFn =
* ECMA-262 19.2.4 over a string argument (non-string arguments keep the
* fence: Node would ToNumber-coerce). Borrows; never throws. */
| "num.parseFloat"
/* ToNumber(string) — ECMA-262 7.1.4.1 StringToNumber (scr_string.c):
* trim the JS StrWhiteSpace set, empty/whitespace-only → +0, then the
* whole span must be one StrNumericLiteral — signed decimal (Infinity
* included, strtod-over-validated-span correct rounding) or unsigned
* 0x/0o/0b (exact value, nearest-even; signed forms are NaN) — with
* any trailing garbage answering NaN. Number(aString), unary + on
* strings, and util.format %d over strings lower here. Borrows; never
* throws. */
| "num.fromString"
/** The static URI component codecs (scr_string.c), ECMA-262 Encode/
* Decode with the component sets over the runtime's UTF-8 strings.
* str.encodeUriComponent percent-encodes every byte outside the
+1
View File
@@ -195,6 +195,7 @@ export const LIB_FN_SIGS: Record<IrLibFn, { argTypes: (IrType | null)[]; result:
"math.random": { argTypes: [], result: F64 },
"num.parseInt": { argTypes: [STRING, F64], result: F64 },
"num.parseFloat": { argTypes: [STRING], result: F64 },
"num.fromString": { argTypes: [STRING], result: F64 },
"num.isNaN": { argTypes: [F64], result: BOOL },
"str.encodeUriComponent": { argTypes: [STRING], result: STRING },
// The base64 globals: the argument is a DOM value (WebIDL ToString
+26
View File
@@ -0,0 +1,26 @@
// Number(aString) over argv-style input — the CLI idiom
// (`Number(process.argv[2])`): the args are pushed onto the REAL
// process.argv (one interned array on both sides) so the reads exercise
// exactly the runtime string path a user's flag parsing takes.
process.argv.push("42", "-3.5", " 7 ", "0x1f", "not-a-number", "", "1e3", "+.5");
const n = Number(process.argv[2]);
console.log(n, n + 1, Number.isNaN(n));
console.log(Number(process.argv[3]), Number(process.argv[4]));
console.log(Number(process.argv[5]), Number(process.argv[6]));
console.log(Number(process.argv[7]), Number(process.argv[8]), Number(process.argv[9]));
// The parsed values feed arithmetic and comparisons like real flag handling.
const port = Number(process.argv[2]);
const timeout = Number(process.argv[8]);
console.log(port > 0 && port < 65536, timeout * 2, port + timeout);
// A defaulting pattern: NaN from garbage falls back.
function intFlag(raw: string, fallback: number): number {
const v = Number(raw);
return Number.isNaN(v) ? fallback : v;
}
console.log(intFlag(process.argv[6], 100), intFlag(process.argv[4], 100), intFlag(process.argv[7], 8080));
// Cleanup so argv reads elsewhere would see the original shape.
for (let i = 0; i < 8; i++) process.argv.pop();
console.log(process.argv.length);
+40
View File
@@ -0,0 +1,40 @@
// The ToNumber(string) grammar zoo — Number(aString) through the ECMA-262
// StringNumericLiteral grammar edge by edge: the full StrWhiteSpace set
// (empty/whitespace-only is +0, lookalikes are not whitespace), signed
// decimals with exponents, "Infinity" exact-case, UNSIGNED 0x/0o/0b
// (signs on those are NaN), boundary doubles (denormals, MAX_VALUE
// overflow, the 2^53 neighborhood, giant hex), and trailing garbage.
const zoo: string[] = [
// whitespace: empty, ASCII, NBSP/BOM/Zs/line terminators, lookalikes
"", " ", "\t\n\r\v\f", "\u00a0\ufeff", "\u2000\u2009\u202f\u205f\u3000\u1680", "\u2028\u2029",
" 42 ", "\t7\n", "\u00a08\ufeff", "\u200b", "1\u00a02", "\u180e5",
// decimals: signs, dots, exponents
"42", "-42", "+42", "0", "-0", "+0", ".5", "5.", "1.", "+.5", "-.5",
"3.14159", "1e3", "1E3", "1e+3", "1e-3", "5.e3", ".5e-3", "1e", "1e+", ".e3", ".",
// Infinity: exact-case whole-token only
"Infinity", "-Infinity", "+Infinity", " Infinity ", "infinity", "INFINITY", "Infinit", "Infinityy",
// non-decimal integer literals: unsigned only, whole-span only
"0x10", "0X1F", "0xdeadBEEF", "0o17", "0O777", "0b101", "0B11",
"-0x10", "+0o7", "-0b1", "0x", "0o", "0b", "0xg", "0o8", "0b2", "0x1.5", " 0x11 ",
// boundaries: 2^53 neighbors, MAX_VALUE overflow, denormal underflow, giant hex
"9007199254740991", "9007199254740992", "9007199254740993", "9007199254740995",
"0x1fffffffffffff", "0x20000000000000", "0x20000000000001", "0x20000000000002",
"0xffffffffffffffff", "0xfffffffffffff800000000000000000000",
"1.7976931348623157e308", "1.7976931348623159e308", "2e308", "-1e309",
"5e-324", "2.5e-324", "2e-324", "1e-400", "2.2250738585072014e-308",
// garbage: every one NaN
"12px", "1.2.3", "1..2", "1_000", "1,000", "+-1", "1 2", "+ 1", "NaN", "nan",
"e5", "+", "-", "true", "null", "undefined", "00x10", "\u0661\u0662\u0663",
];
for (const s of zoo) {
// 1/x distinguishes -0 from +0 (prints -Infinity vs Infinity).
console.log(JSON.stringify(s), Number(s), 1 / Number(s));
}
// Long-form inputs: exactness and overflow through the whole pipeline.
console.log(Number("9".repeat(100)), Number("1" + "0".repeat(309)), Number("0." + "0".repeat(100) + "1"));
console.log(Number("0x" + "f".repeat(300)), Number("0b" + "1".repeat(54)), Number("0o" + "7".repeat(400)));
// Results are ordinary doubles: arithmetic and comparisons.
const a = Number("0.1"), b = Number("0.2");
console.log(a + b, a + b === 0.3, Number("1e21") * 2, Number(" ") + 5);
+26
View File
@@ -0,0 +1,26 @@
// Unary + over strings IS ToNumber — the same StringToNumber lowering
// Number(aString) takes — and util.format %d rides it too: strings
// convert before formatting. Also the classic +x idioms: sort
// comparators over numeric strings and quick input coercion.
console.log(+"42", +"-3.5", +" 7 ", +"", +" ", +"0x1f", +"1e3");
console.log(+"Infinity", +"-Infinity", +"12px", +"0b101", +"-0x1");
console.log(1 / +"-0", 1 / +"", +"." , +".5"); // 1/x tells -0 from +0
// Parenthesized and nested forms; the result is an ordinary double.
const raw = "19.75";
console.log(+raw + 0.25, -+raw, +raw * 2, +raw === 19.75);
// The sort-by-numeric-value idiom over string data.
const ids = ["10", "2", "33", "4"];
ids.sort((a, b) => +a - +b);
console.log(ids.join(","));
// util.format %d: numbers as-is, booleans 1/0, and now strings through
// ToNumber (console.log's format path is util.format).
console.log("port %d retries %d", "8080", "3");
console.log("bad %d empty %d spaced %d", "12px", "", " 42 ");
console.log("hex %d inf %d neg %d", "0xff", "Infinity", "-2.5");
// Chained coercion in conditions and template strings.
const flag = "0";
console.log(+flag === 0 ? "off" : "on", `${+"3" + +"4"}`);