Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions crates/perry-runtime/src/intl/number_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,25 @@ pub(crate) fn grouping_enabled(use_grouping: &str, int_len: usize) -> bool {
}
}

/// Locale-specific display symbol for USD. Most locales use "$"; Korean and
/// Traditional/Simplified Chinese use "US$" to disambiguate from local dollars.
fn usd_symbol(locale: &str) -> &'static str {
if locale.starts_with("ko") || locale.starts_with("zh") {
"US$"
} else {
"$"
}
}

/// Locale-specific NaN string (e.g. zh-TW uses "非數值").
fn nan_string(locale: &str) -> &'static str {
if locale.starts_with("zh") {
"非數值"
} else {
"NaN"
}
}

/// Compact-notation suffix tables for `en` (short and long forms).
pub(crate) fn compact_suffix(power: u32, long: bool) -> &'static str {
match (power, long) {
Expand Down Expand Up @@ -1302,7 +1321,7 @@ fn number_parts_core(r: &NfResolved, value: f64) -> Vec<(&'static str, String)>
// NaN is non-negative and non-zero for sign purposes: only `always`
// prepends a (plus) sign — `+NaN` — every other mode shows bare `NaN`.
push_sign(&mut parts, &r.sign_display, false, true);
parts.push(("nan", "NaN".to_string()));
parts.push(("nan", nan_string(&r.locale).to_string()));
push_style_suffix(&mut parts, r, decimal_sep);
return parts;
}
Expand Down Expand Up @@ -1525,11 +1544,14 @@ pub(crate) fn currency_instance_parts(r: &NfResolved, value: f64) -> Vec<(&'stat
// increment grid and the displayed precision agree — e.g. 3 fraction digits
// snap on 0.005 steps, not the currency-default 0.05.
let frac_digits = r.max_frac as usize;
// Capture original sign before any rounding.
let is_negative = value < 0.0 || (value == 0.0 && value.is_sign_negative());
let accounting = r.currency_sign == "accounting";
// The native float renderer below doesn't honor roundingIncrement; when set,
// snap the magnitude onto the increment grid first (digit-string rounding,
// respecting roundingMode) so the renderer formats an already-gridded value.
let value = if r.rounding_increment != 1.0 && value.is_finite() {
let negative = value < 0.0 || (value == 0.0 && value.is_sign_negative());
let negative = is_negative;
set_round_ctx(&r.rounding_mode, negative);
let abs = value.abs();
let shortest = format!("{abs}");
Expand All @@ -1548,12 +1570,20 @@ pub(crate) fn currency_instance_parts(r: &NfResolved, value: f64) -> Vec<(&'stat
} else {
value
};
let digits = format_number_parts(value, locale, Some(frac_digits), None);
// For accounting sign, pass the absolute value so format_number_parts does
// not emit a minus-sign segment — we wrap the assembled parts in "()" below.
let format_value = if accounting && is_negative {
value.abs()
} else {
value
};
let digits = format_number_parts(format_value, locale, Some(frac_digits), None);
let mut numeric: Vec<(&'static str, String)> = Vec::new();
split_numeric_parts(&digits, locale, &mut numeric);
Comment on lines +1580 to 1582

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Localize NaN in the currency path too.

currency_instance_parts still routes NaN through format_number_parts, which returns literal "NaN", so zh-TW currency formatting bypasses the new nan_string behavior.

Proposed fix
-    let digits = format_number_parts(format_value, locale, Some(frac_digits), None);
     let mut numeric: Vec<(&'static str, String)> = Vec::new();
-    split_numeric_parts(&digits, locale, &mut numeric);
+    if format_value.is_nan() {
+        numeric.push(("nan", nan_string(locale).to_string()));
+    } else {
+        let digits = format_number_parts(format_value, locale, Some(frac_digits), None);
+        split_numeric_parts(&digits, locale, &mut numeric);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let digits = format_number_parts(format_value, locale, Some(frac_digits), None);
let mut numeric: Vec<(&'static str, String)> = Vec::new();
split_numeric_parts(&digits, locale, &mut numeric);
let mut numeric: Vec<(&'static str, String)> = Vec::new();
if format_value.is_nan() {
numeric.push(("nan", nan_string(locale).to_string()));
} else {
let digits = format_number_parts(format_value, locale, Some(frac_digits), None);
split_numeric_parts(&digits, locale, &mut numeric);
}
🤖 Prompt for 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.

In `@crates/perry-runtime/src/intl/number_format.rs` around lines 1580 - 1582, The
currency formatting path still sends NaN through format_number_parts, so it
bypasses the locale-specific nan_string handling. Update currency_instance_parts
to detect NaN before building numeric parts and use the same localized NaN
branch used by the non-currency formatter, preserving the existing
split_numeric_parts flow for normal numbers. Keep the fix centered around
currency_instance_parts and the number formatting helpers it already calls.

let de_style = locale.eq_ignore_ascii_case("de") || locale.starts_with("de-");
let mut parts: Vec<(&'static str, String)> = Vec::new();
match r.currency.as_deref() {
Some("EUR") if locale.starts_with("de") => {
Some("EUR") if de_style => {
parts = numeric;
parts.push(("literal", "\u{00a0}".to_string()));
parts.push(("currency", "\u{20ac}".to_string()));
Expand All @@ -1562,8 +1592,14 @@ pub(crate) fn currency_instance_parts(r: &NfResolved, value: f64) -> Vec<(&'stat
parts.push(("currency", "\u{20ac}".to_string()));
parts.extend(numeric);
}
Some("USD") if de_style => {
// de-DE places the currency symbol after the number with NBSP.
parts = numeric;
parts.push(("literal", "\u{00a0}".to_string()));
parts.push(("currency", usd_symbol(locale).to_string()));
}
Some("USD") => {
parts.push(("currency", "$".to_string()));
parts.push(("currency", usd_symbol(locale).to_string()));
parts.extend(numeric);
}
Some(code) => {
Expand All @@ -1573,6 +1609,12 @@ pub(crate) fn currency_instance_parts(r: &NfResolved, value: f64) -> Vec<(&'stat
}
None => parts = numeric,
}
// Accounting sign: negative amounts are wrapped in parentheses (no minus sign).
// Only applied when signDisplay is not "never".
if accounting && is_negative && r.sign_display != "never" {
parts.insert(0, ("literal", "(".to_string()));
parts.push(("literal", ")".to_string()));
}
Comment on lines +1612 to +1617

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve rounded-zero signDisplay semantics for accounting.

Line 1614 wraps every originally negative value except signDisplay: "never". That regresses the existing rounded-zero behavior in number_parts_core, where exceptZero and negative suppress the sign after rounding to zero.

Proposed fix
     split_numeric_parts(&digits, locale, &mut numeric);
     let de_style = locale.eq_ignore_ascii_case("de") || locale.starts_with("de-");
+    let has_digits = numeric
+        .iter()
+        .any(|(t, _)| *t == "integer" || *t == "fraction");
+    let rounded_is_zero = has_digits
+        && numeric
+            .iter()
+            .filter(|(t, _)| *t == "integer" || *t == "fraction")
+            .all(|(_, v)| v.bytes().all(|b| b == b'0'));
     let mut parts: Vec<(&'static str, String)> = Vec::new();
@@
-    if accounting && is_negative && r.sign_display != "never" {
+    let show_accounting_sign = match r.sign_display.as_str() {
+        "never" => false,
+        "exceptZero" | "negative" => is_negative && !rounded_is_zero,
+        _ => is_negative,
+    };
+    if accounting && show_accounting_sign {
         parts.insert(0, ("literal", "(".to_string()));
         parts.push(("literal", ")".to_string()));
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Accounting sign: negative amounts are wrapped in parentheses (no minus sign).
// Only applied when signDisplay is not "never".
if accounting && is_negative && r.sign_display != "never" {
parts.insert(0, ("literal", "(".to_string()));
parts.push(("literal", ")".to_string()));
}
split_numeric_parts(&digits, locale, &mut numeric);
let de_style = locale.eq_ignore_ascii_case("de") || locale.starts_with("de-");
let has_digits = numeric
.iter()
.any(|(t, _)| *t == "integer" || *t == "fraction");
let rounded_is_zero = has_digits
&& numeric
.iter()
.filter(|(t, _)| *t == "integer" || *t == "fraction")
.all(|(_, v)| v.bytes().all(|b| b == b'0'));
let mut parts: Vec<(&'static str, String)> = Vec::new();
// Accounting sign: negative amounts are wrapped in parentheses (no minus sign).
// Only applied when signDisplay is not "never".
let show_accounting_sign = match r.sign_display.as_str() {
"never" => false,
"exceptZero" | "negative" => is_negative && !rounded_is_zero,
_ => is_negative,
};
if accounting && show_accounting_sign {
parts.insert(0, ("literal", "(".to_string()));
parts.push(("literal", ")".to_string()));
}
🤖 Prompt for 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.

In `@crates/perry-runtime/src/intl/number_format.rs` around lines 1612 - 1617, The
accounting branch in number formatting currently wraps every originally negative
value unless signDisplay is "never", which breaks the rounded-zero behavior
already handled in number_parts_core. Update the logic around the accounting
sign handling in number_parts_core so parentheses are only applied when the
post-rounding sign still qualifies for the active sign_display, preserving the
existing suppress-sign behavior for exceptZero and negative when the rounded
result is zero.

parts
}

Expand Down
76 changes: 68 additions & 8 deletions crates/perry-runtime/src/intl/number_format_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti
// values like 3 or 5000.1.
let rounding_increment = read_rounding_increment(options);

let rounding_mode = get_string_option_enum(
let rounding_mode = enum_option_strict(
options,
"roundingMode",
&[
Expand Down Expand Up @@ -293,12 +293,72 @@ pub(crate) fn is_well_formed_currency_code(code: &str) -> bool {
code.len() == 3 && code.bytes().all(|b| b.is_ascii_alphabetic())
}

/// A core unit identifier is a `-`-separated sequence of lowercase ASCII
/// segments (optionally a `per-` compound). This is a structural check, not a
/// validity check against the CLDR sanctioned-unit list.
/// ECMA-402 Table 2 — sanctioned single unit identifiers (includes hyphenated
/// atoms like "fluid-ounce" and "mile-scandinavian").
const SANCTIONED_UNITS: &[&str] = &[
"acre",
"bit",
"byte",
"celsius",
"centimeter",
"day",
"degree",
"fahrenheit",
"fluid-ounce",
"foot",
"gallon",
"gigabit",
"gigabyte",
"gram",
"hectare",
"hour",
"inch",
"kilobit",
"kilobyte",
"kilogram",
"kilometer",
"liter",
"megabit",
"megabyte",
"meter",
"microsecond",
"mile",
"mile-scandinavian",
"milliliter",
"millimeter",
"millisecond",
"minute",
"month",
"nanosecond",
"ounce",
"percent",
"petabyte",
"pound",
"second",
"stone",
"terabit",
"terabyte",
"week",
"yard",
"year",
];

fn is_sanctioned_single_unit(unit: &str) -> bool {
SANCTIONED_UNITS.contains(&unit)
}

/// ECMA-402 IsWellFormedUnitIdentifier: a simple sanctioned unit, or a
/// compound `<sanctioned>-per-<sanctioned>` with exactly one `-per-` separator.
pub(crate) fn is_well_formed_unit_identifier(unit: &str) -> bool {
!unit.is_empty()
&& unit
.split('-')
.all(|seg| !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_alphabetic()))
if is_sanctioned_single_unit(unit) {
return true;
}
match unit.split_once("-per-") {
Some((numerator, denominator)) => {
!denominator.contains("-per-")
&& is_sanctioned_single_unit(numerator)
&& is_sanctioned_single_unit(denominator)
}
None => false,
}
}
Loading