|
| 1 | +//! `-u-nu-` (numbering system) Unicode-extension resolution for the Intl |
| 2 | +//! service constructors. Splitting these BCP-47 tag helpers out of `intl.rs` |
| 3 | +//! keeps that namespace module under the repository's 2,000-line gate. |
| 4 | +
|
| 5 | +/// A `numberingSystem` value is structurally valid when it is one or more |
| 6 | +/// hyphen-separated subtags of 3–8 alphanumerics (the `type` Unicode nonterminal). |
| 7 | +pub(super) fn is_well_formed_numbering_system(value: &str) -> bool { |
| 8 | + !value.is_empty() |
| 9 | + && value.split('-').all(|sub| { |
| 10 | + (3..=8).contains(&sub.len()) && sub.bytes().all(|b| b.is_ascii_alphanumeric()) |
| 11 | + }) |
| 12 | +} |
| 13 | + |
| 14 | +/// A numbering system is *supported* when it is the default `latn` (Latin/ASCII |
| 15 | +/// digits, which need no transliteration table) or Perry has a digit table for |
| 16 | +/// it. This is the set `resolvedOptions().numberingSystem` may report; other |
| 17 | +/// (e.g. algorithmic) systems are treated as unsupported and fall back to `latn`. |
| 18 | +pub(super) fn is_supported_numbering_system(name: &str) -> bool { |
| 19 | + name == "latn" || super::number_format_digits::numbering_system_digits(name).is_some() |
| 20 | +} |
| 21 | + |
| 22 | +/// ResolveLocale for the `nu` (numbering system) Unicode extension key |
| 23 | +/// (ECMA-402): reconciles the requested locale's `-u-nu-` keyword with an |
| 24 | +/// explicit `options.numberingSystem`, and returns the resolved |
| 25 | +/// `(locale, numberingSystem)` pair. `opt_ns` is the already-validated, |
| 26 | +/// lower-cased option value (or `None`). The resolved locale keeps `-u-nu-X` |
| 27 | +/// only when `X` is the *supported* value actually used AND it originated from |
| 28 | +/// the locale extension (i.e. an option that differs from a supported extension |
| 29 | +/// drops the keyword). See NumberFormat resolved-numbering-system test262. |
| 30 | +pub(super) fn resolve_numbering_system(locale: &str, opt_ns: Option<&str>) -> (String, String) { |
| 31 | + let ext_ns = |
| 32 | + numbering_system_from_locale(locale).filter(|ns| is_supported_numbering_system(ns)); |
| 33 | + let opt_supported = opt_ns.filter(|ns| is_supported_numbering_system(ns)); |
| 34 | + |
| 35 | + let (resolved_ns, keep_ext) = match (opt_supported, &ext_ns) { |
| 36 | + // Option present and supported: it wins; the locale keyword survives only |
| 37 | + // when it names the same value. |
| 38 | + (Some(opt), ext) => (opt.to_string(), ext.as_deref() == Some(opt)), |
| 39 | + // No usable option: fall back to the supported extension, else default. |
| 40 | + (None, Some(ext)) => (ext.clone(), true), |
| 41 | + (None, None) => ("latn".to_string(), false), |
| 42 | + }; |
| 43 | + |
| 44 | + let resolved_locale = if keep_ext { |
| 45 | + with_numbering_system_keyword(locale, &resolved_ns) |
| 46 | + } else { |
| 47 | + strip_numbering_system_keyword(locale) |
| 48 | + }; |
| 49 | + (resolved_locale, resolved_ns) |
| 50 | +} |
| 51 | + |
| 52 | +/// Extract the `-u-nu-<value>` numbering system from a (canonicalized) locale |
| 53 | +/// string, lower-cased. Returns `None` when no `nu` keyword is present. |
| 54 | +pub(super) fn numbering_system_from_locale(locale: &str) -> Option<String> { |
| 55 | + let lower = locale.to_ascii_lowercase(); |
| 56 | + let subtags: Vec<&str> = lower.split('-').collect(); |
| 57 | + let u = subtags.iter().position(|s| *s == "u")?; |
| 58 | + let mut i = u + 1; |
| 59 | + while i < subtags.len() { |
| 60 | + let key = subtags[i]; |
| 61 | + // A keyword key is exactly two chars; everything up to the next key is its value. |
| 62 | + if key.len() == 2 { |
| 63 | + if key == "nu" { |
| 64 | + let mut value = String::new(); |
| 65 | + let mut j = i + 1; |
| 66 | + while j < subtags.len() && subtags[j].len() != 2 { |
| 67 | + if !value.is_empty() { |
| 68 | + value.push('-'); |
| 69 | + } |
| 70 | + value.push_str(subtags[j]); |
| 71 | + j += 1; |
| 72 | + } |
| 73 | + return (!value.is_empty()).then_some(value); |
| 74 | + } |
| 75 | + i += 1; |
| 76 | + while i < subtags.len() && subtags[i].len() != 2 { |
| 77 | + i += 1; |
| 78 | + } |
| 79 | + } else { |
| 80 | + // Hit another singleton extension (e.g. `-t-`); `nu` lives only under `u`. |
| 81 | + break; |
| 82 | + } |
| 83 | + } |
| 84 | + None |
| 85 | +} |
| 86 | + |
| 87 | +/// Split a locale tag into `(base, u_keywords, tail_after_u)`, where |
| 88 | +/// `u_keywords` is the ordered list of `(key, value)` pairs inside the `-u-` |
| 89 | +/// extension and `tail` is everything from the next singleton onward (e.g. a |
| 90 | +/// `-t-`/`-x-` sequence). Returns `None` when the tag has no `-u-` extension. |
| 91 | +/// The `base` keeps its original canonical casing (`en-US`); the extension / |
| 92 | +/// tail regions are lower-cased per UTS #35. |
| 93 | +fn split_u_extension(locale: &str) -> Option<(String, Vec<(String, Vec<String>)>, String)> { |
| 94 | + // Preserve the base region's casing (`en-US` must not become `en-us`); only |
| 95 | + // the extension region is canonically lower-cased. |
| 96 | + let subtags: Vec<&str> = locale.split('-').collect(); |
| 97 | + let u = subtags.iter().position(|s| s.eq_ignore_ascii_case("u"))?; |
| 98 | + let base = subtags[..u].join("-"); |
| 99 | + let lower: Vec<String> = subtags.iter().map(|s| s.to_ascii_lowercase()).collect(); |
| 100 | + |
| 101 | + let mut keywords: Vec<(String, Vec<String>)> = Vec::new(); |
| 102 | + let mut i = u + 1; |
| 103 | + let mut tail_start = subtags.len(); |
| 104 | + while i < subtags.len() { |
| 105 | + let sub = lower[i].as_str(); |
| 106 | + if sub.len() == 1 { |
| 107 | + // Next singleton (`t`/`x`/…) ends the `u` extension. |
| 108 | + tail_start = i; |
| 109 | + break; |
| 110 | + } |
| 111 | + // A keyword key is exactly two chars; the value runs until the next key. |
| 112 | + if sub.len() == 2 { |
| 113 | + let key = sub.to_string(); |
| 114 | + let mut value = Vec::new(); |
| 115 | + let mut j = i + 1; |
| 116 | + while j < subtags.len() && lower[j].len() != 2 && lower[j].len() != 1 { |
| 117 | + value.push(lower[j].clone()); |
| 118 | + j += 1; |
| 119 | + } |
| 120 | + keywords.push((key, value)); |
| 121 | + i = j; |
| 122 | + } else { |
| 123 | + // An `-u-` attribute (3+ chars with no preceding key). Keep it as a |
| 124 | + // value-less pseudo-keyword so round-tripping doesn't drop it. |
| 125 | + keywords.push((sub.to_string(), Vec::new())); |
| 126 | + i += 1; |
| 127 | + } |
| 128 | + } |
| 129 | + let tail = if tail_start < subtags.len() { |
| 130 | + lower[tail_start..].join("-") |
| 131 | + } else { |
| 132 | + String::new() |
| 133 | + }; |
| 134 | + Some((base, keywords, tail)) |
| 135 | +} |
| 136 | + |
| 137 | +/// Reassemble a locale from a `split_u_extension` decomposition, dropping the |
| 138 | +/// `-u-` extension entirely when no keywords remain. |
| 139 | +fn rebuild_locale(base: &str, keywords: &[(String, Vec<String>)], tail: &str) -> String { |
| 140 | + let mut out = base.to_string(); |
| 141 | + if !keywords.is_empty() { |
| 142 | + out.push_str("-u"); |
| 143 | + for (key, value) in keywords { |
| 144 | + out.push('-'); |
| 145 | + out.push_str(key); |
| 146 | + for v in value { |
| 147 | + out.push('-'); |
| 148 | + out.push_str(v); |
| 149 | + } |
| 150 | + } |
| 151 | + } |
| 152 | + if !tail.is_empty() { |
| 153 | + out.push('-'); |
| 154 | + out.push_str(tail); |
| 155 | + } |
| 156 | + out |
| 157 | +} |
| 158 | + |
| 159 | +/// Remove the `-u-nu-<value>` keyword from a locale tag (dropping the whole |
| 160 | +/// `-u-` extension if it becomes empty). A tag with no `nu` keyword is returned |
| 161 | +/// unchanged. |
| 162 | +fn strip_numbering_system_keyword(locale: &str) -> String { |
| 163 | + let Some((base, mut keywords, tail)) = split_u_extension(locale) else { |
| 164 | + return locale.to_string(); |
| 165 | + }; |
| 166 | + keywords.retain(|(key, _)| key != "nu"); |
| 167 | + rebuild_locale(&base, &keywords, &tail) |
| 168 | +} |
| 169 | + |
| 170 | +/// Ensure the locale tag carries `-u-nu-<ns>` (adding a `-u-` extension if |
| 171 | +/// absent, or replacing an existing `nu` value). |
| 172 | +fn with_numbering_system_keyword(locale: &str, ns: &str) -> String { |
| 173 | + let (base, mut keywords, tail) = match split_u_extension(locale) { |
| 174 | + Some(parts) => parts, |
| 175 | + None => (locale.to_string(), Vec::new(), String::new()), |
| 176 | + }; |
| 177 | + let value = vec![ns.to_string()]; |
| 178 | + if let Some(entry) = keywords.iter_mut().find(|(key, _)| key == "nu") { |
| 179 | + entry.1 = value; |
| 180 | + } else { |
| 181 | + keywords.push(("nu".to_string(), value)); |
| 182 | + } |
| 183 | + rebuild_locale(&base, &keywords, &tail) |
| 184 | +} |
0 commit comments