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
62 changes: 14 additions & 48 deletions crates/perry-runtime/src/intl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ mod list_relative_plural;
mod number_format;
mod number_format_digits;
mod number_format_options;
mod numbering_system;
use numbering_system::{is_well_formed_numbering_system, resolve_numbering_system};
mod segmenter;

pub(crate) use date_collator::{
Expand Down Expand Up @@ -441,50 +443,6 @@ fn currency_fraction_digits(code: &str) -> u32 {
}
}

/// A `numberingSystem` value is structurally valid when it is one or more
/// hyphen-separated subtags of 3–8 alphanumerics (the `type` Unicode nonterminal).
fn is_well_formed_numbering_system(value: &str) -> bool {
!value.is_empty()
&& value.split('-').all(|sub| {
(3..=8).contains(&sub.len()) && sub.bytes().all(|b| b.is_ascii_alphanumeric())
})
}

/// Extract the `-u-nu-<value>` numbering system from a (canonicalized) locale
/// string, lower-cased. Returns `None` when no `nu` keyword is present.
fn numbering_system_from_locale(locale: &str) -> Option<String> {
let lower = locale.to_ascii_lowercase();
let subtags: Vec<&str> = lower.split('-').collect();
let u = subtags.iter().position(|s| *s == "u")?;
let mut i = u + 1;
while i < subtags.len() {
let key = subtags[i];
// A keyword key is exactly two chars; everything up to the next key is its value.
if key.len() == 2 {
if key == "nu" {
let mut value = String::new();
let mut j = i + 1;
while j < subtags.len() && subtags[j].len() != 2 {
if !value.is_empty() {
value.push('-');
}
value.push_str(subtags[j]);
j += 1;
}
return (!value.is_empty()).then_some(value);
}
i += 1;
while i < subtags.len() && subtags[i].len() != 2 {
i += 1;
}
} else {
// Hit another singleton extension (e.g. `-t-`); `nu` lives only under `u`.
break;
}
}
None
}

#[cold]
fn throw_type_error(message: &str) -> ! {
let msg = js_string_from_bytes(message.as_ptr(), message.len() as u32);
Expand Down Expand Up @@ -1125,15 +1083,23 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option
)),
}
}
// `numberingSystem` must be a well-formed `type` nonterminal.
if let Some(ns) = get_locale_extension_option(options, "numberingSystem") {
// `numberingSystem` must be a well-formed `type` nonterminal. Read
// it here (preserving the GetOption order options-order.js asserts),
// then run ResolveLocale for `nu` — reconciling the option with the
// locale's `-u-nu-` keyword so `resolvedOptions().locale` /
// `.numberingSystem` reflect only the supported value actually used.
let dtf_opt_ns = get_locale_extension_option(options, "numberingSystem").map(|ns| {
if !is_well_formed_numbering_system(&ns) {
throw_range_error(&format!(
"Value {ns} out of range for Intl options property numberingSystem"
));
}
set_internal_field(obj, KEY_NUMBERING_SYSTEM, string_value(&ns));
}
ns.to_ascii_lowercase()
});
let (dtf_locale, dtf_numbering) =
resolve_numbering_system(&locale, dtf_opt_ns.as_deref());
set_internal_field(obj, KEY_LOCALE, string_value(&dtf_locale));
set_internal_field(obj, KEY_NUMBERING_SYSTEM, string_value(&dtf_numbering));
// hour12 (boolean) then hourCycle (enum) — both only surface in
// `resolvedOptions` when the resolved pattern has an hour field.
if let Some(h12) = get_bool_option(options, "hour12") {
Expand Down
12 changes: 9 additions & 3 deletions crates/perry-runtime/src/intl/duration_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,17 +236,23 @@ pub(super) fn configure(obj: *mut ObjectHeader, options: f64) {
"best fit",
);

let numbering = match df_get_option_string(options, "numberingSystem") {
let opt_ns = match df_get_option_string(options, "numberingSystem") {
Some(ns) => {
if !valid_numbering_system(&ns) {
throw_range_error(&format!(
"Value {ns} out of range for Intl.DurationFormat options property numberingSystem"
));
}
ns
Some(ns.to_ascii_lowercase())
}
None => "latn".to_string(),
None => None,
};
// ResolveLocale for `nu`: reconcile the option with the requested locale's
// `-u-nu-` keyword (stored in KEY_LOCALE at construction) and update both the
// resolved locale and numbering system.
let locale = get_string_field(obj, KEY_LOCALE).unwrap_or_else(|| "en-US".to_string());
let (resolved_locale, numbering) = super::resolve_numbering_system(&locale, opt_ns.as_deref());
set_internal_field(obj, KEY_LOCALE, string_value(&resolved_locale));
set_internal_field(obj, KEY_DF_NUMBERING, string_value(&numbering));

let base_style = df_enum_option(
Expand Down
14 changes: 9 additions & 5 deletions crates/perry-runtime/src/intl/number_format_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,24 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti
"best fit",
);

// numberingSystem: option (validated, lower-cased) overrides the locale
// `-u-nu-` keyword; default "latn".
let numbering = match get_option_string(options, "numberingSystem") {
// numberingSystem: validate the option (well-formed `type` nonterminal),
// then run ResolveLocale for the `nu` key — reconciling the option with the
// requested locale's `-u-nu-` keyword and updating the resolved locale so
// `resolvedOptions().locale` reflects only the supported value actually used.
let opt_ns = match get_option_string(options, "numberingSystem") {
Some(value) => {
let lower = value.to_ascii_lowercase();
if !is_well_formed_numbering_system(&lower) {
throw_range_error(&format!(
"Value {value} out of range for Intl.NumberFormat options property numberingSystem"
));
}
lower
Some(lower)
}
None => numbering_system_from_locale(locale).unwrap_or_else(|| "latn".to_string()),
None => None,
};
let (resolved_locale, numbering) = resolve_numbering_system(locale, opt_ns.as_deref());
set_internal_field(obj, KEY_LOCALE, string_value(&resolved_locale));
set_internal_field(obj, KEY_NF_NUMBERING, string_value(&numbering));

// SetNumberFormatUnitOptions.
Expand Down
184 changes: 184 additions & 0 deletions crates/perry-runtime/src/intl/numbering_system.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! `-u-nu-` (numbering system) Unicode-extension resolution for the Intl
//! service constructors. Splitting these BCP-47 tag helpers out of `intl.rs`
//! keeps that namespace module under the repository's 2,000-line gate.

/// A `numberingSystem` value is structurally valid when it is one or more
/// hyphen-separated subtags of 3–8 alphanumerics (the `type` Unicode nonterminal).
pub(super) fn is_well_formed_numbering_system(value: &str) -> bool {
!value.is_empty()
&& value.split('-').all(|sub| {
(3..=8).contains(&sub.len()) && sub.bytes().all(|b| b.is_ascii_alphanumeric())
})
}

/// A numbering system is *supported* when it is the default `latn` (Latin/ASCII
/// digits, which need no transliteration table) or Perry has a digit table for
/// it. This is the set `resolvedOptions().numberingSystem` may report; other
/// (e.g. algorithmic) systems are treated as unsupported and fall back to `latn`.
pub(super) fn is_supported_numbering_system(name: &str) -> bool {
name == "latn" || super::number_format_digits::numbering_system_digits(name).is_some()
}

/// ResolveLocale for the `nu` (numbering system) Unicode extension key
/// (ECMA-402): reconciles the requested locale's `-u-nu-` keyword with an
/// explicit `options.numberingSystem`, and returns the resolved
/// `(locale, numberingSystem)` pair. `opt_ns` is the already-validated,
/// lower-cased option value (or `None`). The resolved locale keeps `-u-nu-X`
/// only when `X` is the *supported* value actually used AND it originated from
/// the locale extension (i.e. an option that differs from a supported extension
/// drops the keyword). See NumberFormat resolved-numbering-system test262.
pub(super) fn resolve_numbering_system(locale: &str, opt_ns: Option<&str>) -> (String, String) {
let ext_ns =
numbering_system_from_locale(locale).filter(|ns| is_supported_numbering_system(ns));
let opt_supported = opt_ns.filter(|ns| is_supported_numbering_system(ns));

let (resolved_ns, keep_ext) = match (opt_supported, &ext_ns) {
// Option present and supported: it wins; the locale keyword survives only
// when it names the same value.
(Some(opt), ext) => (opt.to_string(), ext.as_deref() == Some(opt)),
// No usable option: fall back to the supported extension, else default.
(None, Some(ext)) => (ext.clone(), true),
(None, None) => ("latn".to_string(), false),
};

let resolved_locale = if keep_ext {
with_numbering_system_keyword(locale, &resolved_ns)
} else {
strip_numbering_system_keyword(locale)
};
(resolved_locale, resolved_ns)
}

/// Extract the `-u-nu-<value>` numbering system from a (canonicalized) locale
/// string, lower-cased. Returns `None` when no `nu` keyword is present.
pub(super) fn numbering_system_from_locale(locale: &str) -> Option<String> {
let lower = locale.to_ascii_lowercase();
let subtags: Vec<&str> = lower.split('-').collect();
let u = subtags.iter().position(|s| *s == "u")?;
let mut i = u + 1;
while i < subtags.len() {
let key = subtags[i];
// A keyword key is exactly two chars; everything up to the next key is its value.
if key.len() == 2 {
if key == "nu" {
let mut value = String::new();
let mut j = i + 1;
while j < subtags.len() && subtags[j].len() != 2 {
if !value.is_empty() {
value.push('-');
}
value.push_str(subtags[j]);
j += 1;
}
return (!value.is_empty()).then_some(value);
}
i += 1;
while i < subtags.len() && subtags[i].len() != 2 {
i += 1;
}
} else {
// Hit another singleton extension (e.g. `-t-`); `nu` lives only under `u`.
break;
}
}
None
}
Comment on lines +54 to +85

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 | 🟡 Minor | ⚡ Quick win

numbering_system_from_locale mis-parses -u- attributes and singleton boundaries, diverging from split_u_extension.

Two defects in this hand-rolled scan:

  1. The else branch (lines 79-82) breaks on any subtag whose length isn't 2, so a leading -u- attribute (3–8 chars, e.g. en-u-attr-nu-latn) is treated as "another singleton" and the nu keyword is never found.
  2. The value loop while subtags[j].len() != 2 (line 66) never stops at a following singleton (len 1). For a valid tag like en-u-nu-arab-x-foo it collects arab-x-foo, which fails is_supported_numbering_system, so ext_ns becomes None and resolution wrongly falls back to latn instead of arab.

split_u_extension already handles attributes and singleton boundaries correctly. Delegating to it removes the divergent parser and fixes both cases.

🐛 Proposed fix: reuse the correct decomposition
 pub(super) fn numbering_system_from_locale(locale: &str) -> Option<String> {
-    let lower = locale.to_ascii_lowercase();
-    let subtags: Vec<&str> = lower.split('-').collect();
-    let u = subtags.iter().position(|s| *s == "u")?;
-    let mut i = u + 1;
-    while i < subtags.len() {
-        let key = subtags[i];
-        // A keyword key is exactly two chars; everything up to the next key is its value.
-        if key.len() == 2 {
-            if key == "nu" {
-                let mut value = String::new();
-                let mut j = i + 1;
-                while j < subtags.len() && subtags[j].len() != 2 {
-                    if !value.is_empty() {
-                        value.push('-');
-                    }
-                    value.push_str(subtags[j]);
-                    j += 1;
-                }
-                return (!value.is_empty()).then_some(value);
-            }
-            i += 1;
-            while i < subtags.len() && subtags[i].len() != 2 {
-                i += 1;
-            }
-        } else {
-            // Hit another singleton extension (e.g. `-t-`); `nu` lives only under `u`.
-            break;
-        }
-    }
-    None
+    let (_, keywords, _) = split_u_extension(locale)?;
+    keywords
+        .into_iter()
+        .find(|(key, _)| key == "nu")
+        .map(|(_, value)| value.join("-"))
+        .filter(|v| !v.is_empty())
 }
📝 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
pub(super) fn numbering_system_from_locale(locale: &str) -> Option<String> {
let lower = locale.to_ascii_lowercase();
let subtags: Vec<&str> = lower.split('-').collect();
let u = subtags.iter().position(|s| *s == "u")?;
let mut i = u + 1;
while i < subtags.len() {
let key = subtags[i];
// A keyword key is exactly two chars; everything up to the next key is its value.
if key.len() == 2 {
if key == "nu" {
let mut value = String::new();
let mut j = i + 1;
while j < subtags.len() && subtags[j].len() != 2 {
if !value.is_empty() {
value.push('-');
}
value.push_str(subtags[j]);
j += 1;
}
return (!value.is_empty()).then_some(value);
}
i += 1;
while i < subtags.len() && subtags[i].len() != 2 {
i += 1;
}
} else {
// Hit another singleton extension (e.g. `-t-`); `nu` lives only under `u`.
break;
}
}
None
}
pub(super) fn numbering_system_from_locale(locale: &str) -> Option<String> {
let (_, keywords, _) = split_u_extension(locale)?;
keywords
.into_iter()
.find(|(key, _)| key == "nu")
.map(|(_, value)| value.join("-"))
.filter(|v| !v.is_empty())
}
🤖 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/numbering_system.rs` around lines 54 - 85,
`numbering_system_from_locale` is using a custom scan that mishandles `-u-`
attributes and singleton boundaries, so it can miss `nu` or overrun into later
extensions. Update this function to reuse the same U-extension decomposition
logic as `split_u_extension`, and then read the `nu` keyword from that parsed
structure instead of manually walking subtags. Ensure the fix preserves correct
handling for leading attributes and stops cleanly at the next singleton
extension.


/// Split a locale tag into `(base, u_keywords, tail_after_u)`, where
/// `u_keywords` is the ordered list of `(key, value)` pairs inside the `-u-`
/// extension and `tail` is everything from the next singleton onward (e.g. a
/// `-t-`/`-x-` sequence). Returns `None` when the tag has no `-u-` extension.
/// The `base` keeps its original canonical casing (`en-US`); the extension /
/// tail regions are lower-cased per UTS #35.
fn split_u_extension(locale: &str) -> Option<(String, Vec<(String, Vec<String>)>, String)> {
// Preserve the base region's casing (`en-US` must not become `en-us`); only
// the extension region is canonically lower-cased.
let subtags: Vec<&str> = locale.split('-').collect();
let u = subtags.iter().position(|s| s.eq_ignore_ascii_case("u"))?;
let base = subtags[..u].join("-");
let lower: Vec<String> = subtags.iter().map(|s| s.to_ascii_lowercase()).collect();

let mut keywords: Vec<(String, Vec<String>)> = Vec::new();
let mut i = u + 1;
let mut tail_start = subtags.len();
while i < subtags.len() {
let sub = lower[i].as_str();
if sub.len() == 1 {
// Next singleton (`t`/`x`/…) ends the `u` extension.
tail_start = i;
break;
}
// A keyword key is exactly two chars; the value runs until the next key.
if sub.len() == 2 {
let key = sub.to_string();
let mut value = Vec::new();
let mut j = i + 1;
while j < subtags.len() && lower[j].len() != 2 && lower[j].len() != 1 {
value.push(lower[j].clone());
j += 1;
}
keywords.push((key, value));
i = j;
} else {
// An `-u-` attribute (3+ chars with no preceding key). Keep it as a
// value-less pseudo-keyword so round-tripping doesn't drop it.
keywords.push((sub.to_string(), Vec::new()));
i += 1;
}
}
let tail = if tail_start < subtags.len() {
lower[tail_start..].join("-")
} else {
String::new()
};
Some((base, keywords, tail))
}

/// Reassemble a locale from a `split_u_extension` decomposition, dropping the
/// `-u-` extension entirely when no keywords remain.
fn rebuild_locale(base: &str, keywords: &[(String, Vec<String>)], tail: &str) -> String {
let mut out = base.to_string();
if !keywords.is_empty() {
out.push_str("-u");
for (key, value) in keywords {
out.push('-');
out.push_str(key);
for v in value {
out.push('-');
out.push_str(v);
}
}
}
if !tail.is_empty() {
out.push('-');
out.push_str(tail);
}
out
}

/// Remove the `-u-nu-<value>` keyword from a locale tag (dropping the whole
/// `-u-` extension if it becomes empty). A tag with no `nu` keyword is returned
/// unchanged.
fn strip_numbering_system_keyword(locale: &str) -> String {
let Some((base, mut keywords, tail)) = split_u_extension(locale) else {
return locale.to_string();
};
keywords.retain(|(key, _)| key != "nu");
rebuild_locale(&base, &keywords, &tail)
}

/// Ensure the locale tag carries `-u-nu-<ns>` (adding a `-u-` extension if
/// absent, or replacing an existing `nu` value).
fn with_numbering_system_keyword(locale: &str, ns: &str) -> String {
let (base, mut keywords, tail) = match split_u_extension(locale) {
Some(parts) => parts,
None => (locale.to_string(), Vec::new(), String::new()),
};
let value = vec![ns.to_string()];
if let Some(entry) = keywords.iter_mut().find(|(key, _)| key == "nu") {
entry.1 = value;
} else {
keywords.push(("nu".to_string(), value));
}
rebuild_locale(&base, &keywords, &tail)
}
Loading