Skip to content

fix(intl): #5581 — NumberFormat numbering systems, roundingIncrement, zero-significant hang#5737

Merged
proggeramlug merged 5 commits into
mainfrom
worktree-intl-5580-5581-5582
Jun 28, 2026
Merged

fix(intl): #5581 — NumberFormat numbering systems, roundingIncrement, zero-significant hang#5737
proggeramlug merged 5 commits into
mainfrom
worktree-intl-5580-5581-5582

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Three spec-grounded fixes to Intl.NumberFormat rendering, surfaced by the #5581 test262 cluster (intl402/NumberFormat). 31 test262 cases flip to pass, with zero regressions across the NumberFormat / DurationFormat / PluralRules trees.

1. Numbering-system digit transliteration

Output digits were always Latin (latn). A formatter resolved to a non-Latin numbering system — via -u-nu-arab in the locale or the numberingSystem option — still emitted ASCII digits, failing the digit-set gate in test262's testNumberFormat helper.

  • numbering_system_digits(name) returns the 10 glyphs for a system (the full 77-system table, generated from test262's own numberingSystemDigits so it stays authoritative).
  • transliterate_parts_digits rewrites only the digit-bearing segments (integer / fraction / exponentInteger); separators, signs, and currency/unit/compact literals keep their locale glyphs.
  • Applied in the shared number_parts_from_resolved core, so format, formatToParts, and the Intl.DurationFormat consumer all pick it up. latn / unknown systems are a no-op.

2. roundingIncrement

The option was validated and surfaced in resolvedOptions but never applied during rendering, so every roundingIncrement ≠ 1 case formatted as if it were 1.

  • round_to_increment scales the value to an integer and rounds to the nearest multiple of the increment, breaking ties (including the fractional tail) via the shared decision core.
  • The ApplyUnsignedRoundingMode logic was factored out of rounding_up into round_decision, so fraction- and increment-rounding share one mode implementation (no behavior change to the existing fraction path).

3. Significant-digit formatting of zero infinite-looped

round_to_significant(0, …) produced ("0", "") then ran while significant_count(int, frac) < min_sig { frac.push('0') } — but significant_count of all-zeros is always 0, so format(0) hung forever under significant-digit or compact notation (every locale, not numbering-specific). Now zero renders as a single "0" padded to min_sig total digits (ECMA-402 ToRawPrecision: minSig 3 → "0.00") and returns early. This latent hang was only reached once fix #1 cleared the digit-set gate in the rounding-mode cases.

Verification

scripts/test262_subset.py --dir intl402/NumberFormat (Node v26.3 oracle):

before after
pass 127 158
runtime-fail 114 83

Flipped (31): all 14 format-rounding-increment-*, all 9 format-rounding-mode-*, numbering-systems.js, format-significant-digits*, and the fraction cases that were gated on the non-Latin digit assertion. Diffed the full tree against the issue's failure list and against the pre-change run — 0 regressions. DurationFormat (92 pass) and PluralRules (39 pass) unchanged.

Scope

Partial progress on #5581. The remaining NumberFormat clusters (units, accounting-currency sign, compact-locale tables, formatRange/formatRangeToParts) and the broader #5580 (Temporal toLocaleString) / #5582 (DateTimeFormat) clusters are deeper, separate efforts and are not addressed here.

Per maintainer convention, no version bump / CHANGELOG entry — left for merge time.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Extended locale number formatting to support non‑Latin numbering systems by transliterating only digit glyphs while keeping signs, separators, and literals unchanged.
    • Improved rounding for roundingIncrement values other than 1.0, including fixed-fraction increment rounding for general formatting and currency rendering.
  • Bug Fixes
    • Refined “half-way” rounding behavior for more consistent tie-breaking (including half-even).
    • Corrected zero formatting in significant-digit mode to return 0 with the expected padded fraction length.

…n + roundingIncrement

Two spec-grounded gaps in `Intl.NumberFormat` rendering, both surfaced by the
intl402/NumberFormat test262 cluster (#5581):

1. **Numbering systems.** Output digits were always Latin (`latn`); a formatter
   resolved to a non-Latin system (`-u-nu-arab`, the `numberingSystem` option,
   etc.) still emitted ASCII digits. Added `numbering_system_digits` (the full
   77-system glyph table, generated from test262's `numberingSystemDigits`) and
   a `transliterate_parts_digits` pass that rewrites the digit glyphs of the
   `integer`/`fraction`/`exponentInteger` segments — separators, signs, and
   currency/unit/compact literals keep their locale glyphs. Applied in the
   shared `number_parts_from_resolved` core so `format`, `formatToParts`, and
   the DurationFormat consumer all benefit; `latn`/unknown is a no-op.

2. **roundingIncrement.** The option was validated and surfaced in
   resolvedOptions but never applied during rendering. Added `round_to_increment`
   (rounds the scaled integer to the nearest multiple of the increment, breaking
   ties via the shared `round_decision` core) and wired it into the standard
   notation branch. Refactored the ApplyUnsignedRoundingMode logic out of
   `rounding_up` into `round_decision` so fraction- and increment-rounding share
   one mode implementation.

Flips 21 intl402/NumberFormat test262 cases (all 14 roundingIncrement tests,
numbering-systems.js, and the fraction/significant cases that were gated on the
non-Latin digit assertion) with zero regressions across the tree.

Partial progress on #5581; the remaining NumberFormat clusters (units, accounting
currency, compact-locale tables, formatRange) and the #5580/#5582 Temporal/
DateTimeFormat clusters are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 49 seconds. Learn how PR review limits work.

To continue reviewing without waiting, enable usage-based billing in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 60e90019-3bb9-4ed4-9a3d-1a85d902c3d8

📥 Commits

Reviewing files that changed from the base of the PR and between 61cc67f and 4e83e80.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/intl/number_format.rs
📝 Walkthrough

Walkthrough

Refactors rounding behavior, adds non-unit increment rounding, pads zero significant-digit output, and transliterates digit parts for resolved numbering systems.

Changes

Intl.NumberFormat rounding and digit rendering

Layer / File(s) Summary
rounding helpers and increment rounding
crates/perry-runtime/src/intl/number_format.rs
rounding_up now classifies half cases before delegating to round_decision. round_to_increment rounds fixed-width fractions for non-unit increments and reconstructs padded (int, frac) output. round_to_significant now pads the zero-value fraction to min_sig digits.
numbering system digit transliteration
crates/perry-runtime/src/intl/number_format.rs
numbering_system_digits returns digit glyphs for numbering-system names. transliterate_parts_digits rewrites only integer, fraction, and exponentInteger parts, and number_parts_from_resolved applies it after core formatting.
rounding increment wiring across render paths
crates/perry-runtime/src/intl/number_format.rs
Scientific, engineering, default, and compact non-significant-digit flows now use round_fraction_or_increment. Currency rendering also pre-grids finite values onto roundingIncrement steps before the normal currency formatter runs.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#5603: This PR’s roundingIncrement and rounding-mode plumbing connects directly to the rounding behavior consumed here in number_format.rs.

Poem

🐇 I hop through digits, bright and neat,
Half-ties now land with proper beat.
Tiny steps round where they should,
And glyphs take shape in numbering wood.
A rabbit grin, and format’s sweet.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR modifies Intl.NumberFormat rendering, not the requested macOS menu bar APIs in #1. Implement the custom macOS menu bar item API from #1, or move these Intl.NumberFormat fixes to the correct issue or PR.
Out of Scope Changes check ⚠️ Warning The Intl.NumberFormat rounding and digit-rendering work is unrelated to the menu bar feature requested in #1. Remove the unrelated Intl.NumberFormat changes from this PR and limit the branch to the custom menu bar item implementation.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is specific and accurately summarizes the main Intl.NumberFormat fixes in the changeset.
Description check ✅ Passed The description covers summary, concrete changes, related issue context, verification, and scope, despite not matching every template heading exactly.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-intl-5580-5581-5582

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/intl/number_format.rs`:
- Around line 799-807: The roundingIncrement handling in number_parts_core is
only applied in the standard decimal branch, so configs like nearest 0.05 are
skipped by the currency and scientific/engineering/compact paths. Update the
rounding flow in number_parts_core and the downstream helpers it dispatches to
so rounding_increment is honored before those branches format the number, using
the existing round_to_increment logic (or an equivalent shared step) rather than
only the standard decimal path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f334ba62-8dce-4682-9396-92b78d2450d1

📥 Commits

Reviewing files that changed from the base of the PR and between 58e1b98 and 9852cd2.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/intl/number_format.rs

Comment thread crates/perry-runtime/src/intl/number_format.rs Outdated
…infinite-looped

`round_to_significant` rendered a zero value as `("0", "")` and then ran the
min-significant-digits padding loop `while significant_count(int, frac) < min_sig
{ frac.push('0') }`. But `significant_count` of an all-zeros decimal is always 0,
so for any value of `0` formatted with significant digits (or compact notation,
which rounds by significant digits) the loop never terminated — `format(0)` hung.

The hang was latent (every locale, not numbering-specific); the preceding
numbering-system commit merely unblocked the test262 rounding-mode cases far
enough past the digit-set gate to reach `format(0)` and expose it.

Render zero as a single "0" padded to `min_sig` total displayed digits
(ECMA-402 ToRawPrecision: minSig 3 → "0.00") and return early, before the
nonzero-only normalization loops.

Flips the 9 intl402/NumberFormat `format-rounding-mode-*` cases plus
format-significant-digits.js (+10); no regressions across NumberFormat /
DurationFormat / PluralRules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@proggeramlug proggeramlug changed the title fix(intl): #5581 — NumberFormat numbering-system digits + roundingIncrement fix(intl): #5581 — NumberFormat numbering systems, roundingIncrement, zero-significant hang Jun 27, 2026
…pact paths (CodeRabbit)

Addresses CodeRabbit review on #5737: `roundingIncrement` was only applied in the
standard decimal branch, so currency, scientific/engineering, and the
fraction-mode compact path still ignored it (e.g. nearest-0.05 configs).

Per ECMA-402 roundingIncrement is gated on fixed fraction-digit rounding, not on
notation, and Node applies it uniformly — to the currency value, the scientific
*mantissa*, and the compact value, all at maxFrac places. Factored the
fraction-rounding step into a shared `round_fraction_or_increment` helper and
routed the standard, scientific/engineering, and compact branches through it;
the currency path (a separate native-float renderer) snaps its magnitude onto
the increment grid up front via the same digit-string `round_to_increment`,
respecting roundingMode.

With `roundingIncrement == 1` (the overwhelmingly common case) the helper is
byte-identical to the previous `round_to_fraction` + `trim_fraction`, so existing
output is unchanged. Verified perry now matches Node byte-for-byte for currency
(nearest 0.05), scientific (mantissa rounding), and compact increment configs.

NumberFormat test262 unchanged at 158 pass / identical failure set (these
notation+increment combos have no test262 coverage); DurationFormat (92) and
PluralRules (39) unchanged — zero regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/intl/number_format.rs`:
- Around line 901-925: The increment-snapping path in number_format.rs is using
the currency default fraction count instead of the resolved fraction width, so
rounding grids can be wrong when minimumFractionDigits/maximumFractionDigits are
customized. Update the rounding logic around the existing frac_digits/value
block to derive the snap scale from the resolved fraction width used by
format_number_parts, and feed that into round_to_increment instead of
currency_fraction_digits. Keep the behavior inside the rounding_increment branch
and preserve the existing rounding_mode handling in set_round_ctx.
- Around line 755-758: In the scientific/engineering formatting path in
number_format.rs, the exponent is computed before rounding, but
round_to_significant and round_fraction_or_increment can still grow the mantissa
into an extra digit (for example 9.9 becoming 10). Update the formatting logic
around this rounding result so that after calling those helpers you detect
mantissa overflow and renormalize the exponent accordingly before emitting the
final scientific string, ensuring cases like 10E0 become 1E1.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9c3405b9-c013-43e3-b158-03ed480c3a9d

📥 Commits

Reviewing files that changed from the base of the PR and between 53a8cab and 0e74aaf.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/intl/number_format.rs

Comment thread crates/perry-runtime/src/intl/number_format.rs Outdated
Comment thread crates/perry-runtime/src/intl/number_format.rs Outdated
Reflow the generated numbering-system digit table and round_to_increment to
satisfy cargo fmt --check (lint gate). Whitespace only — no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/intl/number_format.rs`:
- Around line 252-256: The overflow path in `round_to_increment` is dropping
`roundingIncrement` when `combined[..cut].parse::<u128>()` fails for large
`maximumFractionDigits` values. Update the fallback handling around the `let
Ok(q) = ... else` branch so it stays increment-aware instead of returning
`fallback()`/`round_to_fraction`; use the existing decimal-string data
(`combined`, `cut`, and `increment`) to classify the remainder and adjust the
value without relying on `u128` parsing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a376620-ffbe-4df3-96af-bf73722c10e0

📥 Commits

Reviewing files that changed from the base of the PR and between 0e74aaf and 61cc67f.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/intl/number_format.rs

Comment thread crates/perry-runtime/src/intl/number_format.rs Outdated
…rency scale, mantissa carry)

Three correctness findings from CodeRabbit on #5737, each verified byte-for-byte
against Node:

1. roundingIncrement on wide fraction scales / large values. `round_to_increment`
   parsed the scaled integer as `u128`, so `maximumFractionDigits` near its 100
   ceiling (or very large values) overflowed and silently fell back to plain
   fraction rounding, dropping the increment. The scaled integer is now kept as a
   digit string and reduced with small-divisor modular arithmetic
   (decimal_mod_small / decimal_add_small / decimal_sub_small); the increment
   (≤ 5000) and the dropped tail (bounded by the shortest decimal) stay small.
   Parity for halfEven is read off `q mod 2·increment`, avoiding any wide division.

2. Currency increment grid used the default scale. `currency_instance_parts`
   rounded/snapped on the currency's default fraction digits, ignoring
   minimum/maximumFractionDigits — so e.g. 3-digit USD snapped on 0.05 instead of
   0.005. It now uses the resolved width `r.max_frac` (which already folds in the
   currency default plus options) for both the increment grid and the display,
   so grid and precision agree (1.234 → $1.235). Identical to before for the
   default-width common case.

3. Scientific/engineering mantissa carry. The exponent was computed before
   rounding, so a carry (9.9 → 10) emitted `10E0` instead of `1E1`. After
   rounding, a grown mantissa now renormalizes: recompute the exponent from
   msd+1 and reshape the significand — scientific `1E1`, while engineering keeps
   the digit when the new magnitude stays in the same power-of-1000 band
   (`9.9 → 10E0`, `999.9 → 1E3`, `99999 → 100E3`).

NumberFormat test262 unchanged at 158 pass / identical failure set (these cases
have no test262 coverage in the failing set); DurationFormat (92) and PluralRules
(39) unchanged — zero regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@proggeramlug proggeramlug merged commit d04ddf5 into main Jun 28, 2026
15 checks passed
@proggeramlug proggeramlug deleted the worktree-intl-5580-5581-5582 branch June 28, 2026 07:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support custom menu bar items

1 participant