From 4b23d34efe1007a4ee7132e33d45e5a02f9932ba Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 08:17:31 -0700 Subject: [PATCH] Format toString(radix) with a number radix without a dyn box n.toString(radix) passed the radix as a dyn value, so every call boxed a number and converted it back, and kept scr_json.c's dyn machinery linked into programs that use nothing else of it. A number-typed radix now calls scr_num_to_string_radix_f64, which truncates it as ToIntegerOrInfinity does and holds the formatting that moved from scr_json.c to scr_lib.c. Other radix types keep the generic entry point, which converts and then calls the same function. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/lower-calls.ts | 24 +++++++- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 3 + packages/runtime/src/scr_json.c | 56 +----------------- packages/runtime/src/scr_lib.c | 58 +++++++++++++++++++ packages/runtime/src/scr_number.h | 1 + tests/corpus/6160-number-radix-typed.ts | 36 ++++++++++++ 8 files changed, 126 insertions(+), 54 deletions(-) create mode 100644 tests/corpus/6160-number-radix-typed.ts diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index f69c96e6e..a72a5f4b6 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -144,6 +144,7 @@ export const LIB_FN_SYMS: Record = { "num.toFixed0": "scr_num_to_fixed0", "num.toFixed": "scr_num_to_fixed", "num.toStringRadix": "scr_num_to_string_radix", + "num.toStringRadixF64": "scr_num_to_string_radix_f64", "num.sameValue": "scr_num_same_value", "intl.numFormatEnUs": "scr_intl_num_format_en_us", "number.isFinite": "scr_num_is_finite", diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index b3094d5c7..f3b344a44 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4105,10 +4105,32 @@ function lowerNumberToStringCall( } if (call.arguments.length === 1) { if (operand.type.kind !== "f64") return null; + const arg = call.arguments[0]!; + // A number radix needs no box: ToIntegerOrInfinity of a number is a + // truncation, which the typed entry point applies itself. + if (!ts.isSpreadElement(arg) && lowerer.mapTypeOf(lowerer.typeOf(arg))?.kind === "f64") { + const radix = lowerer.lowerExpr(arg); + if (radix.type.kind === "f64") { + return { + kind: "libCall", + fn: "num.toStringRadixF64", + args: [operand, radix], + type: STRING, + loc: locOf(call), + }; + } + return { + kind: "libCall", + fn: "num.toStringRadix", + args: [operand, lowerer.coerceToExpected(radix, DYN)], + type: STRING, + loc: locOf(call), + }; + } return { kind: "libCall", fn: "num.toStringRadix", - args: [operand, lowerer.lowerExprExpecting(call.arguments[0]!, DYN)], + args: [operand, lowerer.lowerExprExpecting(arg, DYN)], type: STRING, loc: locOf(call), }; diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 1bb9cbd14..2e844fc26 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -137,6 +137,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fetch.streamFrom", "num.toFixed", "num.toStringRadix", + "num.toStringRadixF64", "insp.jsonDyn", // diagnostics_channel: publish runs subscribers synchronously (a throw // propagates — the documented divergence from triggerUncaughtException); diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index ca2749591..9cdad7366 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -751,6 +751,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "num.toFixed0": { argTypes: [F64], result: STRING }, "num.toFixed": { argTypes: [F64, F64], result: STRING }, "num.toStringRadix": { argTypes: [F64, DYN], result: STRING }, + /** toString(radix) with a number radix (scr_lib.c): truncates it as + * ToIntegerOrInfinity does, THROWS RangeError outside 2..36. */ + "num.toStringRadixF64": { argTypes: [F64, F64], result: STRING }, /** Object.is over two numbers — the spec's SameValue on doubles: NaN * equals NaN, +0 differs from -0, everything else is `===`. Plain bool * result; never throws. (Union-armed operands take unionEq's sameValue diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index b23e78241..9a3f315d2 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -13045,63 +13045,13 @@ double scr_buffer_byte_length_dyn(ScrDyn *value, ScrStr *encoding) { return 0; } -/* Radix digit generation follows V8's DoubleToRadixStringView algorithm. - * Copyright 2012 the V8 project authors. BSD license in - * ../vendor/v8-number-radix/LICENSE. The decimal path retains Ryū. */ +/* toString(radix) with any radix value: undefined means 10, anything else + * converts with ToNumber (which can run program code) first. */ ScrStr *scr_num_to_string_radix(double value, const ScrDyn *argument) { double requested = 10; if (argument && argument->kind != SCR_DYN_UNDEF && !scr_dyn_number_coerce_js(argument, &requested)) return NULL; - requested = isnan(requested) ? 0 : trunc(requested); - if (requested < 2 || requested > 36 || !isfinite(requested)) { - static const char message[] = "toString() radix argument must be between 2 and 36"; - scr_throw_error_msg(SCR_ERR_RANGE, message, sizeof message - 1); - return NULL; - } - int radix = (int)requested; - if (radix == 10 || !isfinite(value) || value == 0) return scr_f64_to_scrstr(value); - static const char digits[] = "0123456789abcdefghijklmnopqrstuvwxyz"; - char buffer[2200]; - const size_t middle = sizeof buffer / 2; - size_t begin = middle, end = middle; - bool negative = value < 0; - value = fabs(value); - double integer = floor(value), fraction = value - integer; - double tolerance = 0.5 * (nextafter(value, INFINITY) - value); - if (tolerance <= 0) tolerance = nextafter(0, INFINITY); - if (fraction >= tolerance) { - buffer[end++] = '.'; - do { - fraction *= radix; - tolerance *= radix; - int digit = (int)fraction; - buffer[end++] = digits[digit]; - fraction -= digit; - if ((fraction > 0.5 || (fraction == 0.5 && (digit & 1))) && fraction + tolerance > 1) { - for (;;) { - end--; - if (end == middle) { integer += 1; break; } - char last = buffer[end]; - digit = last > '9' ? last - 'a' + 10 : last - '0'; - if (digit + 1 < radix) { buffer[end++] = digits[digit + 1]; break; } - } - break; - } - } while (fraction >= tolerance); - } - /* V8's Double::Exponent is the unbiased exponent minus 52. Beyond - * that precision boundary, trailing digits are unrepresented zeros. */ - while (integer / radix >= 0x1p53) { - integer /= radix; - buffer[--begin] = '0'; - } - do { - double remainder = fmod(integer, radix); - buffer[--begin] = digits[(int)remainder]; - integer = (integer - remainder) / radix; - } while (integer > 0); - if (negative) buffer[--begin] = '-'; - return scr_str_new(buffer + begin, end - begin); + return scr_num_to_string_radix_f64(value, requested); } ScrDyn *scr_caught_to_dyn(const ScrCaught *c) { diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 9307d9b01..c7ef8632b 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -10660,6 +10660,64 @@ ScrStr *scr_num_to_fixed0(double x) { * the receiver's non-finite arm, as ECMA-262 requires. Invalid precision * raises V8's catchable RangeError text; otherwise the result is +1. The * exact-binary-value digits come from scr_f64_to_fixed (scr_number.c). */ +/* Number.prototype.toString(radix) with a number radix, which + * ToIntegerOrInfinity truncates (NaN to 0). Radix digit generation follows + * V8's DoubleToRadixStringView algorithm. Copyright 2012 the V8 project + * authors. BSD license in ../vendor/v8-number-radix/LICENSE. The decimal + * path retains Ryū. */ +ScrStr *scr_num_to_string_radix_f64(double value, double requested) { + requested = isnan(requested) ? 0 : trunc(requested); + if (requested < 2 || requested > 36 || !isfinite(requested)) { + static const char message[] = "toString() radix argument must be between 2 and 36"; + scr_throw_error_msg(SCR_ERR_RANGE, message, sizeof message - 1); + return NULL; + } + int radix = (int)requested; + if (radix == 10 || !isfinite(value) || value == 0) return scr_f64_to_scrstr(value); + static const char digits[] = "0123456789abcdefghijklmnopqrstuvwxyz"; + char buffer[2200]; + const size_t middle = sizeof buffer / 2; + size_t begin = middle, end = middle; + bool negative = value < 0; + value = fabs(value); + double integer = floor(value), fraction = value - integer; + double tolerance = 0.5 * (nextafter(value, INFINITY) - value); + if (tolerance <= 0) tolerance = nextafter(0, INFINITY); + if (fraction >= tolerance) { + buffer[end++] = '.'; + do { + fraction *= radix; + tolerance *= radix; + int digit = (int)fraction; + buffer[end++] = digits[digit]; + fraction -= digit; + if ((fraction > 0.5 || (fraction == 0.5 && (digit & 1))) && fraction + tolerance > 1) { + for (;;) { + end--; + if (end == middle) { integer += 1; break; } + char last = buffer[end]; + digit = last > '9' ? last - 'a' + 10 : last - '0'; + if (digit + 1 < radix) { buffer[end++] = digits[digit + 1]; break; } + } + break; + } + } while (fraction >= tolerance); + } + /* V8's Double::Exponent is the unbiased exponent minus 52. Beyond + * that precision boundary, trailing digits are unrepresented zeros. */ + while (integer / radix >= 0x1p53) { + integer /= radix; + buffer[--begin] = '0'; + } + do { + double remainder = fmod(integer, radix); + buffer[--begin] = digits[(int)remainder]; + integer = (integer - remainder) / radix; + } while (integer > 0); + if (negative) buffer[--begin] = '-'; + return scr_str_new(buffer + begin, end - begin); +} + ScrStr *scr_num_to_fixed(double x, double fraction_digits) { double fd = isnan(fraction_digits) ? 0 : trunc(fraction_digits); if (!(fd >= 0 && fd <= 100)) { diff --git a/packages/runtime/src/scr_number.h b/packages/runtime/src/scr_number.h index 84db27075..f980daeb5 100644 --- a/packages/runtime/src/scr_number.h +++ b/packages/runtime/src/scr_number.h @@ -73,6 +73,7 @@ bool scr_num_is_safe_integer(double x); ScrStr *scr_num_to_exponential(double x); ScrStr *scr_num_to_fixed0(double x); ScrStr *scr_num_to_fixed(double x, double fraction_digits); +ScrStr *scr_num_to_string_radix_f64(double value, double radix); /* Object.is over two numbers — the spec's SameValue on doubles: NaN * equals NaN, +0 differs from -0, everything else is ==. Never throws. */ diff --git a/tests/corpus/6160-number-radix-typed.ts b/tests/corpus/6160-number-radix-typed.ts new file mode 100644 index 000000000..4e11e06b2 --- /dev/null +++ b/tests/corpus/6160-number-radix-typed.ts @@ -0,0 +1,36 @@ +// Number.prototype.toString(radix) with a number-typed radix takes the +// typed entry point: ToIntegerOrInfinity truncates the radix (NaN to 0), +// out-of-range radixes throw the same RangeError, and the argument is +// evaluated once, before formatting. An optional radix keeps the generic +// conversion, where undefined means 10. +const radixes = [2, 2.9, 7, 10, 16, 16.5, 35.99, 36]; +const values = [0, -0, 1, -1, 255, -255.5, 0.1, 1 / 3, 2 ** 53, -(2 ** 60), 1e21, 123.456, NaN, Infinity, -Infinity]; +for (const radix of radixes) { + const row: string[] = []; + for (const value of values) row.push(value.toString(radix)); + console.log(radix, row.join(" ")); +} + +let calls = 0; +function nextRadix(): number { + calls++; + return 8 + calls; +} +console.log((1234).toString(nextRadix()), (1234).toString(nextRadix()), calls); + +for (const bad of [-0, 0.5, 1, 1.99, 36.5, 37, -2, NaN, Infinity, -Infinity]) { + try { + console.log((42).toString(bad)); + } catch (error) { + console.log(String(bad), error instanceof RangeError, (error as Error).message); + } +} + +function maybeRadix(i: number): number | undefined { + return i % 2 === 0 ? undefined : 2 + i; +} +for (let i = 0; i < 4; i++) console.log((255).toString(maybeRadix(i))); + +let total = 0; +for (let i = 0; i < 2000; i++) total += (i * 7919).toString(2 + (i % 35)).length; +console.log(total);