From 7ba2b35d5e808cfec3d9398d98db9912e5d10c85 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sat, 24 Jan 2026 16:00:09 +0200 Subject: [PATCH] feat(intl): Add Intl global object and Intl.Locale Expose ECMA-402 Intl namespace with: - getCanonicalLocales() for locale list canonicalization - supportedValuesOf() for querying supported values - Intl.Locale for BCP 47 language tag representation - IntlUtilities with locale canonicalization and resolution Register Intl object in GlobalObject and add Intrinsics.Intl.cs with initial support for Intl and Locale constructors only. Co-Authored-By: Claude Sonnet 4.5 --- Jint/Native/Global/GlobalObject.Properties.cs | 4 +- Jint/Native/Intl/IntlInstance.cs | 159 +- Jint/Native/Intl/IntlUtilities.cs | 1763 +++++++++++++++++ Jint/Native/Intl/JsLocale.cs | 116 ++ Jint/Native/Intl/LocaleConstructor.cs | 1245 +++++++++++- Jint/Native/Intl/LocalePrototype.cs | 396 +++- 6 files changed, 3659 insertions(+), 24 deletions(-) create mode 100644 Jint/Native/Intl/IntlUtilities.cs create mode 100644 Jint/Native/Intl/JsLocale.cs diff --git a/Jint/Native/Global/GlobalObject.Properties.cs b/Jint/Native/Global/GlobalObject.Properties.cs index df25186239..677d20e516 100644 --- a/Jint/Native/Global/GlobalObject.Properties.cs +++ b/Jint/Native/Global/GlobalObject.Properties.cs @@ -31,7 +31,7 @@ public partial class GlobalObject private static readonly Key propertyInt32Array = "Int32Array"; private static readonly Key propertyInt8Array = "Int8Array"; private static readonly Key propertyIterator = "Iterator"; - //private static readonly Key propertyIntl = "Intl"; + private static readonly Key propertyIntl = "Intl"; private static readonly Key propertyJSON = "JSON"; private static readonly Key propertyMap = "Map"; private static readonly Key propertyMath = "Math"; @@ -110,7 +110,7 @@ protected override void Initialize() properties.AddDangerous(propertyInt16Array, new LazyPropertyDescriptor(this, static global => global._realm.Intrinsics.Int16Array, PropertyFlags)); properties.AddDangerous(propertyInt32Array, new LazyPropertyDescriptor(this, static global => global._realm.Intrinsics.Int32Array, PropertyFlags)); properties.AddDangerous(propertyInt8Array, new LazyPropertyDescriptor(this, static global => global._realm.Intrinsics.Int8Array, PropertyFlags)); - // TODO properties.AddDapropertygerous(propertyIntl, new LazyPropertyDescriptor(this, static global => global._realm.Intrinsics.Intl, propertyFlags)); + properties.AddDangerous(propertyIntl, new LazyPropertyDescriptor(this, static global => global._realm.Intrinsics.Intl, PropertyFlags)); properties.AddDangerous(propertyIterator, new LazyPropertyDescriptor(this, static global => global._realm.Intrinsics.Iterator, PropertyFlags)); properties.AddDangerous(propertyJSON, new LazyPropertyDescriptor(this, static global => global._realm.Intrinsics.Json, PropertyFlags)); properties.AddDangerous(propertyMap, new LazyPropertyDescriptor(this, static global => global._realm.Intrinsics.Map, PropertyFlags)); diff --git a/Jint/Native/Intl/IntlInstance.cs b/Jint/Native/Intl/IntlInstance.cs index d8f60823a5..cd52206706 100644 --- a/Jint/Native/Intl/IntlInstance.cs +++ b/Jint/Native/Intl/IntlInstance.cs @@ -1,5 +1,4 @@ -#pragma warning disable CA1859 // Use concrete types when possible for improved performance -- most of prototype methods return JsValue - +using System.Linq; using Jint.Native.Object; using Jint.Native.Symbol; using Jint.Runtime; @@ -24,21 +23,20 @@ internal IntlInstance( _prototype = objectPrototype; } + /// + /// Gets the CLDR provider from engine options. + /// + private ICldrProvider CldrProvider => _engine.Options.Intl.CldrProvider; + protected override void Initialize() { - // TODO check length - var properties = new PropertyDictionary(10, checkExistingKeys: false) - { - ["Collator"] = new(_realm.Intrinsics.Collator, false, false, true), - ["DateTimeFormat"] = new(_realm.Intrinsics.DateTimeFormat, false, false, true), - ["DisplayNames"] = new(_realm.Intrinsics.DisplayNames, false, false, true), - ["ListFormat"] = new(_realm.Intrinsics.ListFormat, false, false, true), - ["Locale"] = new(_realm.Intrinsics.Locale, false, false, true), - ["NumberFormat"] = new(_realm.Intrinsics.NumberFormat, false, false, true), - ["PluralRules"] = new(_realm.Intrinsics.PluralRules, false, false, true), - ["RelativeTimeFormat"] = new(_realm.Intrinsics.RelativeTimeFormat, false, false, true), - ["Segmenter"] = new(_realm.Intrinsics.Segmenter, false, false, true), - ["getCanonicalLocales "] = new(new ClrFunction(Engine, "getCanonicalLocales ", GetCanonicalLocales, 1, PropertyFlag.Configurable), true, false, true), + const PropertyFlag PropertyFlags = PropertyFlag.Writable | PropertyFlag.Configurable; + + var properties = new PropertyDictionary(3, checkExistingKeys: false) + { + ["Locale"] = new(_realm.Intrinsics.Locale, PropertyFlags), + ["getCanonicalLocales"] = new(new ClrFunction(Engine, "getCanonicalLocales", GetCanonicalLocales, 1, PropertyFlag.Configurable), PropertyFlags), + ["supportedValuesOf"] = new(new ClrFunction(Engine, "supportedValuesOf", SupportedValuesOf, 1, PropertyFlag.Configurable), PropertyFlags), }; SetProperties(properties); @@ -49,8 +47,135 @@ protected override void Initialize() SetSymbols(symbols); } - private JsValue GetCanonicalLocales(JsValue thisObject, JsCallArguments arguments) + /// + /// https://tc39.es/ecma402/#sec-intl.getcanonicallocales + /// + private JsArray GetCanonicalLocales(JsValue thisObject, JsCallArguments arguments) + { + var locales = arguments.At(0); + return new JsArray(_engine, IntlUtilities.CanonicalizeLocaleList(_engine, locales).Select(x => new JsString(x)).ToArray()); + } + + /// + /// https://tc39.es/ecma402/#sec-intl.supportedvaluesof + /// + private JsValue SupportedValuesOf(JsValue thisObject, JsCallArguments arguments) { - return new JsArray(_engine); + var key = TypeConverter.ToString(arguments.At(0)); + + string[] values; + switch (key) + { + case "calendar": + values = GetSupportedCalendars(); + break; + case "collation": + values = GetSupportedCollations(); + break; + case "currency": + values = GetSupportedCurrencies(); + break; + case "numberingSystem": + values = GetSupportedNumberingSystems(); + break; + case "timeZone": + values = GetSupportedTimeZones(); + break; + case "unit": + values = GetSupportedUnits(); + break; + default: + Throw.RangeError(_realm, $"Invalid key: {key}"); + return Undefined; + } + + // Sort values alphabetically + System.Array.Sort(values, StringComparer.Ordinal); + + var result = new JsArray(_engine, (uint) values.Length); + for (var i = 0; i < values.Length; i++) + { + result.SetIndexValue((uint) i, values[i], updateLength: true); + } + + return result; + } + + private string[] GetSupportedCalendars() + { + // Use CLDR provider for supported calendars + var calendars = CldrProvider.GetSupportedCalendars(); + var result = new string[calendars.Count]; + var i = 0; + foreach (var calendar in calendars) + { + result[i++] = calendar; + } + return result; + } + + private string[] GetSupportedCollations() + { + // Use CLDR provider for supported collations + var collations = CldrProvider.GetSupportedCollations(); + var result = new string[collations.Count]; + var i = 0; + foreach (var collation in collations) + { + result[i++] = collation; + } + return result; + } + + private string[] GetSupportedCurrencies() + { + // Use CLDR provider for supported currencies + var currencies = CldrProvider.GetSupportedCurrencies(); + var result = new string[currencies.Count]; + var i = 0; + foreach (var currency in currencies) + { + result[i++] = currency; + } + return result; + } + + private string[] GetSupportedNumberingSystems() + { + // Use CLDR provider for supported numbering systems + var systems = CldrProvider.GetSupportedNumberingSystems(); + var result = new string[systems.Count]; + var i = 0; + foreach (var system in systems) + { + result[i++] = system; + } + return result; + } + + private string[] GetSupportedTimeZones() + { + // Use CLDR provider for supported time zones + var zones = CldrProvider.GetSupportedTimeZones(); + var result = new string[zones.Count]; + var i = 0; + foreach (var zone in zones) + { + result[i++] = zone; + } + return result; + } + + private string[] GetSupportedUnits() + { + // Use CLDR provider for supported units + var units = CldrProvider.GetSupportedUnits(); + var result = new string[units.Count]; + var i = 0; + foreach (var unit in units) + { + result[i++] = unit; + } + return result; } } diff --git a/Jint/Native/Intl/IntlUtilities.cs b/Jint/Native/Intl/IntlUtilities.cs new file mode 100644 index 0000000000..8caeb17d82 --- /dev/null +++ b/Jint/Native/Intl/IntlUtilities.cs @@ -0,0 +1,1763 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Jint.Native.Intl.Data; +using Jint.Native.Object; +using Jint.Runtime; + +namespace Jint.Native.Intl; + +/// +/// Shared utility functions for ECMA-402 Internationalization API. +/// https://tc39.es/ecma402/ +/// +internal static class IntlUtilities +{ + // BCP 47 language tag pattern (permissive to accept Unicode extensions) + // Accepts: language[-script][-region][-variant]*[-extension]*[-privateuse] + // Extensions use single-character singletons like -u- for Unicode extensions + // Full spec: https://www.rfc-editor.org/rfc/bcp/bcp47.txt + private static readonly Regex LanguageTagPattern = new( + @"^[a-zA-Z]{2,8}(?:-[a-zA-Z0-9]{1,8})*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + /// + /// Grandfathered tags that map to canonical forms. + /// Only includes regular grandfathered tags that are valid per UTS35. + /// See https://unicode.org/reports/tr35/#BCP_47_Conformance + /// + private static readonly Dictionary GrandfatheredTags = new(StringComparer.OrdinalIgnoreCase) + { + // Regular grandfathered tags that are valid per UTS35 + { "art-lojban", "jbo" }, + { "cel-gaulish", "xtg" }, + { "zh-guoyu", "zh" }, + { "zh-hakka", "hak" }, + { "zh-xiang", "hsn" }, + // Sign language tags with single region code (sgn-XX -> valid language code) + // These are in CLDR aliasData as type="language" + { "sgn-BR", "bzs" }, + { "sgn-CO", "csn" }, + { "sgn-DE", "gsg" }, + { "sgn-DK", "dsl" }, + { "sgn-ES", "ssp" }, + { "sgn-FR", "fsl" }, + { "sgn-GB", "bfi" }, + { "sgn-GR", "gss" }, + { "sgn-IE", "isg" }, + { "sgn-IT", "ise" }, + { "sgn-JP", "jsl" }, + { "sgn-MX", "mfs" }, + { "sgn-NI", "ncs" }, + { "sgn-NL", "dse" }, + { "sgn-NO", "nsl" }, + { "sgn-PT", "psr" }, + { "sgn-SE", "swl" }, + { "sgn-US", "ase" }, + { "sgn-ZA", "sfs" }, + // Note: The following are NOT included because they are invalid per UTS35: + // - no-bok, no-nyn, zh-min, zh-min-nan (regularGrandfatheredNonUTS35) + // - All irregular grandfathered tags (i-*, en-gb-oed) + // - Sign language grandfathered tags with compound regions (sgn-XX-XX like sgn-be-fr) + }; + + /// + /// Language aliases from CLDR supplemental/languageAlias.xml. + /// Maps deprecated/legacy language codes to their canonical replacements. + /// + private static readonly Dictionary LanguageAliases = new(StringComparer.OrdinalIgnoreCase) + { + // ISO 639-3 to macrolanguage mappings + { "cmn", "zh" }, // Mandarin Chinese -> Chinese + { "arb", "ar" }, // Standard Arabic -> Arabic + { "swh", "sw" }, // Swahili -> Swahili macrolanguage + { "zsm", "ms" }, // Standard Malay -> Malay + + // Legacy/deprecated codes + { "ji", "yi" }, // Yiddish (legacy) + { "iw", "he" }, // Hebrew (legacy ISO 639-1) + { "in", "id" }, // Indonesian (legacy ISO 639-1) + { "jw", "jv" }, // Javanese (legacy) + { "mo", "ro" }, // Moldavian (now Romanian) + { "tl", "fil" }, // Tagalog -> Filipino (debatable, but per CLDR) + { "sh", "sr-Latn" }, // Serbo-Croatian -> Serbian Latin + + // Other mappings + { "aar", "aa" }, + { "abk", "ab" }, + { "afr", "af" }, + { "aka", "ak" }, + { "amh", "am" }, + { "ara", "ar" }, + { "aze", "az" }, + { "bel", "be" }, + { "ben", "bn" }, + { "bod", "bo" }, + { "bos", "bs" }, + { "bul", "bg" }, + { "cat", "ca" }, + { "ces", "cs" }, + { "cym", "cy" }, + { "dan", "da" }, + { "deu", "de" }, + { "ell", "el" }, + { "eng", "en" }, + { "est", "et" }, + { "eus", "eu" }, + { "fas", "fa" }, + { "fin", "fi" }, + { "fra", "fr" }, + { "gle", "ga" }, + { "glg", "gl" }, + { "guj", "gu" }, + { "heb", "he" }, + { "hin", "hi" }, + { "hrv", "hr" }, + { "hun", "hu" }, + { "hye", "hy" }, + { "ind", "id" }, + { "isl", "is" }, + { "ita", "it" }, + { "jav", "jv" }, + { "jpn", "ja" }, + { "kan", "kn" }, + { "kat", "ka" }, + { "kaz", "kk" }, + { "khm", "km" }, + { "kor", "ko" }, + { "lao", "lo" }, + { "lat", "la" }, + { "lav", "lv" }, + { "lit", "lt" }, + { "mal", "ml" }, + { "mar", "mr" }, + { "mkd", "mk" }, + { "mlt", "mt" }, + { "mon", "mn" }, + { "msa", "ms" }, + { "mya", "my" }, + { "nep", "ne" }, + { "nld", "nl" }, + { "nor", "no" }, + { "pan", "pa" }, + { "pol", "pl" }, + { "por", "pt" }, + { "pus", "ps" }, + { "ron", "ro" }, + { "rus", "ru" }, + { "sin", "si" }, + { "slk", "sk" }, + { "slv", "sl" }, + { "som", "so" }, + { "spa", "es" }, + { "sqi", "sq" }, + { "srp", "sr" }, + { "swa", "sw" }, + { "swe", "sv" }, + { "tam", "ta" }, + { "tel", "te" }, + { "tha", "th" }, + { "tur", "tr" }, + { "ukr", "uk" }, + { "urd", "ur" }, + { "uzb", "uz" }, + { "vie", "vi" }, + { "yid", "yi" }, + { "zho", "zh" }, + { "zul", "zu" }, + }; + + /// + /// Region aliases from CLDR supplemental/territoryAlias.xml. + /// Maps deprecated/legacy region codes to their canonical replacements. + /// + private static readonly Dictionary RegionAliases = new(StringComparer.OrdinalIgnoreCase) + { + // Historical region codes that have been replaced + { "DD", "DE" }, // East Germany -> Germany + { "YD", "YE" }, // South Yemen -> Yemen + { "AN", "CW" }, // Netherlands Antilles -> Curacao (simplified) + { "CS", "RS" }, // Serbia and Montenegro -> Serbia + { "YU", "RS" }, // Yugoslavia -> Serbia + { "TP", "TL" }, // East Timor (old) -> Timor-Leste + { "ZR", "CD" }, // Zaire -> Democratic Republic of Congo + { "BU", "MM" }, // Burma -> Myanmar + { "SU", "RU" }, // Soviet Union -> Russia (simplified) + { "FX", "FR" }, // Metropolitan France -> France + }; + + /// + /// T extension value aliases for deprecated values. + /// From CLDR supplemental/alias.xml (tvalueAlias). + /// + private static readonly Dictionary TValueAliases = new(StringComparer.OrdinalIgnoreCase) + { + { "names", "prprname" }, // m0-names -> m0-prprname + }; + + /// + /// https://tc39.es/ecma402/#sec-canonicalizelocalelist + /// + internal static List CanonicalizeLocaleList(Engine engine, JsValue locales) + { + // 1. If locales is undefined, return a new empty List. + if (locales.IsUndefined()) + { + return []; + } + + var seen = new List(); + + // 2. If Type(locales) is String or locales has an [[InitializedLocale]] internal slot, then + // let O be CreateArrayFromList(« locales ») + ObjectInstance o; + if (locales.IsString() || locales is JsLocale) + { + o = new JsArray(engine, [locales]); + } + // 2b. If locales is null, throw a TypeError (null is not a valid locales argument) + else if (locales.IsNull()) + { + Throw.TypeError(engine.Realm, "Locales argument must not be null"); + return null!; + } + else + { + // 3. Else let O be ? ToObject(locales). + o = TypeConverter.ToObject(engine.Realm, locales); + } + + // 4. Let len be ? ToLength(? Get(O, "length")). + var len = TypeConverter.ToLength(o.Get(CommonProperties.Length)); + + // 5. For each integer k from 0 to len, do + for (ulong k = 0; k < len; k++) + { + var pk = k.ToString(CultureInfo.InvariantCulture); + + // a. Let kPresent be ? HasProperty(O, Pk). + if (!o.HasProperty(pk)) + { + continue; + } + + // b. If kPresent is true, then + var kValue = o.Get(pk); + + // i. If Type(kValue) is not String or Object, throw a TypeError exception. + // Note: null and undefined are neither String nor Object, so they throw TypeError + if (!kValue.IsString() && !kValue.IsObject()) + { + Throw.TypeError(engine.Realm, "Locale should be a string or object"); + } + + // Note: null is an Object in JS, so we need an explicit check + if (kValue.IsNull()) + { + Throw.TypeError(engine.Realm, "Locale should be a string or object"); + } + + string tag; + + // ii. If Type(kValue) is Object and kValue has an [[InitializedLocale]] internal slot, then + if (kValue is JsLocale jsLocale) + { + tag = jsLocale.Locale; + } + else + { + // iii. Else, let tag be ? ToString(kValue). + tag = TypeConverter.ToString(kValue); + } + + // iv. If ! IsStructurallyValidLanguageTag(tag) is false, throw a RangeError exception. + if (!IsStructurallyValidLanguageTag(tag)) + { + Throw.RangeError(engine.Realm, $"Invalid language tag: {tag}"); + } + + // v. Let canonicalizedTag be ! CanonicalizeUnicodeLocaleId(tag). + var canonicalizedTag = CanonicalizeUnicodeLocaleId(tag); + + // vi. If canonicalizedTag is not an element of seen, append canonicalizedTag to seen. + if (!seen.Contains(canonicalizedTag)) + { + seen.Add(canonicalizedTag); + } + } + + // 6. Return seen. + return seen; + } + + /// + /// https://tc39.es/ecma402/#sec-isstructurallyvalidlanguagetag + /// Per UTS 35, validates Unicode BCP 47 Locale Identifiers. + /// + internal static bool IsStructurallyValidLanguageTag(string locale) + { + if (string.IsNullOrEmpty(locale)) + { + return false; + } + + // Check for invalid characters (non-ASCII, null, whitespace, underscore) + foreach (var c in locale) + { + if (c > 127 || c == '\0' || char.IsWhiteSpace(c) || c == '_') + { + return false; + } + } + + // Basic check: only ASCII letters, digits, and hyphens + if (!LanguageTagPattern.IsMatch(locale)) + { + return false; + } + + var parts = locale.Split('-'); + if (parts.Length == 0 || parts[0].Length == 0) + { + return false; + } + + // First part must be a valid language subtag (2-3 or 5-8 alpha) or grandfathered + var firstPart = parts[0]; + + // Check for private use only tags ("x-...") + if (string.Equals(firstPart, "x", StringComparison.OrdinalIgnoreCase)) + { + // Private use only is invalid in UTS35 + return false; + } + + // Check for extension singleton in first place ("u", "t", etc.) + if (firstPart.Length == 1) + { + return false; + } + + // Check for region code in first place (3 digits like "419") + if (firstPart.Length == 3 && char.IsDigit(firstPart[0])) + { + return false; + } + + // First part must be all letters (language subtag) + foreach (var c in firstPart) + { + if (!char.IsLetter(c)) + { + return false; + } + } + + // Language subtag length: 2-3 or 5-8 letters (4 is script, not allowed as first) + if (firstPart.Length == 4 || firstPart.Length > 8) + { + return false; + } + + // Track seen singletons for duplicate detection + var seenSingletons = new HashSet(); + var seenVariants = new HashSet(StringComparer.OrdinalIgnoreCase); + var inExtension = false; + var extensionType = '\0'; + var extensionHasSubtag = false; + var hasScript = false; + var hasRegion = false; + + for (var i = 1; i < parts.Length; i++) + { + var part = parts[i]; + + if (part.Length == 0) + { + return false; // Empty subtag (double hyphen) + } + + // Check for singleton (extension marker) + if (part.Length == 1) + { + var singleton = char.ToLowerInvariant(part[0]); + + // If we're in private use extension (x-), single chars are just content, not new singletons + if (inExtension && extensionType == 'x') + { + extensionHasSubtag = true; + continue; + } + + // Extension must have at least one subtag after it + if (inExtension && !extensionHasSubtag) + { + return false; + } + + // Check for duplicate singleton + if (seenSingletons.Contains(singleton)) + { + return false; + } + seenSingletons.Add(singleton); + + inExtension = true; + extensionType = singleton; + extensionHasSubtag = false; + continue; + } + + if (inExtension) + { + extensionHasSubtag = true; + + // Private use extension (x-) accepts any subtags, no format validation needed + if (extensionType == 'x') + { + continue; + } + + if (extensionType == 'u') + { + // Unicode extension: ukey must be 2 chars (alphanum + alpha) + if (part.Length == 2) + { + if (!char.IsLetterOrDigit(part[0]) || !char.IsLetter(part[1])) + { + return false; + } + } + } + else if (extensionType == 't') + { + // Transformed extension validation is complex, handled separately below + } + } + else + { + // Not in extension - check for script, region, variant + + if (part.Length == 4 && char.IsLetter(part[0])) + { + // Could be script subtag (4 letters) or variant (4 chars starting with digit) + var isAllLetters = true; + foreach (var c in part) + { + if (!char.IsLetter(c)) + { + isAllLetters = false; + break; + } + } + + if (isAllLetters) + { + // Script subtag + if (hasScript || hasRegion || seenVariants.Count > 0) + { + // Script must come before region and variants + // And only one script allowed + return false; + } + hasScript = true; + } + else if (char.IsDigit(part[0])) + { + // 4-char variant starting with digit + var partLower = part.ToLowerInvariant(); + if (seenVariants.Contains(partLower)) + { + return false; // Duplicate variant + } + seenVariants.Add(partLower); + } + else + { + // 4-char alphanumeric but not script and not starting with digit - invalid + return false; + } + } + else if ((part.Length == 2 && char.IsLetter(part[0])) || + (part.Length == 3 && char.IsDigit(part[0]))) + { + // Region subtag (2 letters or 3 digits) + if (hasRegion) + { + // Only one region allowed + return false; + } + hasRegion = true; + } + else if (part.Length == 4 && char.IsDigit(part[0])) + { + // Variant subtag (4 chars starting with digit, e.g., "1996", "1994") + var partLower = part.ToLowerInvariant(); + if (seenVariants.Contains(partLower)) + { + return false; // Duplicate variant + } + seenVariants.Add(partLower); + } + else if (part.Length >= 5 && part.Length <= 8) + { + // Variant subtag (5-8 alphanumeric) + var partLower = part.ToLowerInvariant(); + if (seenVariants.Contains(partLower)) + { + return false; // Duplicate variant + } + seenVariants.Add(partLower); + } + else + { + // Invalid subtag length for non-extension position + // 3-letter alpha subtags would be extlang (not allowed in UTS35) + // Other lengths are invalid + return false; + } + } + } + + // Extension must have at least one subtag + if (inExtension && !extensionHasSubtag) + { + return false; + } + + // Validate T extension structure if present + if (!ValidateTransformedExtension(locale)) + { + return false; + } + + return true; + } + + /// + /// Validates the structure of a transformed extension (-t-). + /// + private static bool ValidateTransformedExtension(string locale) + { + var tIndex = locale.IndexOf("-t-", StringComparison.OrdinalIgnoreCase); + if (tIndex < 0) + { + return true; // No T extension + } + + // Find the end of the T extension (next singleton or end of string) + var endIndex = locale.Length; + for (var i = tIndex + 3; i < locale.Length - 1; i++) + { + if (locale[i] == '-' && i + 2 < locale.Length && locale[i + 2] == '-' && char.IsLetterOrDigit(locale[i + 1])) + { + var nextChar = locale[i + 1]; + if (char.IsLetter(nextChar) && nextChar != 'x' && nextChar != 'X') + { + // Found another singleton (not x) + endIndex = i; + break; + } + else if (nextChar == 'x' || nextChar == 'X') + { + // Private use starts + endIndex = i; + break; + } + } + } + + var tExtension = locale.Substring(tIndex + 3, endIndex - tIndex - 3); + if (string.IsNullOrEmpty(tExtension)) + { + return false; // Empty T extension + } + + var parts = tExtension.Split('-'); + if (parts.Length == 0 || parts[0].Length == 0) + { + return false; + } + + // Parse T extension: [tlang] [tfield]* + // tlang = unicode_language_subtag (2-3 or 5-8 alpha) ["-" unicode_script_subtag] ["-" unicode_region_subtag] *("-" unicode_variant_subtag) + // tfield = tkey tvalue+ + // tkey = alpha digit + // tvalue = 3-8 alphanum + + var index = 0; + var inTlang = true; + var tlangHasLanguage = false; + var tlangHasScript = false; + var tlangHasRegion = false; + var currentTKeyHasValue = true; // Start true since we don't have a key yet + + while (index < parts.Length) + { + var part = parts[index]; + + // Check if this is a tkey (alpha + digit, 2 chars) + if (part.Length == 2 && char.IsLetter(part[0]) && char.IsDigit(part[1])) + { + // Entering tfield + if (!currentTKeyHasValue) + { + return false; // Previous tkey had no tvalue + } + inTlang = false; + currentTKeyHasValue = false; + index++; + continue; + } + + if (inTlang) + { + // Validate tlang component + if (!tlangHasLanguage) + { + // First part must be language subtag (2-3 or 5-8 alpha) + if (!IsValidTLangLanguage(part)) + { + return false; + } + tlangHasLanguage = true; + } + else if (!tlangHasScript && part.Length == 4 && IsAllLetters(part)) + { + // Script subtag (4 alpha) + tlangHasScript = true; + } + else if (part.Length == 4 && char.IsDigit(part[0])) + { + // 4-char variant starting with digit (e.g., "1994") + // Must come after script/region checks + } + else if (!tlangHasRegion && ((part.Length == 2 && IsAllLetters(part)) || (part.Length == 3 && IsAllDigits(part)))) + { + // Region subtag (2 alpha or 3 digit) + tlangHasRegion = true; + } + else if (IsValidVariant(part)) + { + // Variant subtag (5-8 alphanum or 4 starting with digit) + // OK + } + else + { + // Invalid tlang component (could be extlang which is not allowed) + return false; + } + } + else + { + // Validate tvalue (3-8 alphanum) + if (part.Length < 3 || part.Length > 8) + { + return false; + } + foreach (var c in part) + { + if (!char.IsLetterOrDigit(c)) + { + return false; + } + } + currentTKeyHasValue = true; + } + + index++; + } + + // Final check: if we ended with a tkey, it must have had a tvalue + if (!inTlang && !currentTKeyHasValue) + { + return false; + } + + return true; + } + + private static bool IsValidTLangLanguage(string part) + { + // Language subtag must be 2-3 or 5-8 alpha characters + // 4-letter would be a script, not language + if (part.Length < 2 || part.Length == 4 || part.Length > 8) + { + return false; + } + return IsAllLetters(part); + } + + private static bool IsValidVariant(string part) + { + // Variant is 5-8 alphanum, or 4 chars starting with digit + if (part.Length >= 5 && part.Length <= 8) + { + foreach (var c in part) + { + if (!char.IsLetterOrDigit(c)) return false; + } + return true; + } + if (part.Length == 4 && char.IsDigit(part[0])) + { + foreach (var c in part) + { + if (!char.IsLetterOrDigit(c)) return false; + } + return true; + } + return false; + } + + private static bool IsAllLetters(string part) + { + foreach (var c in part) + { + if (!char.IsLetter(c)) return false; + } + return true; + } + + private static bool IsAllDigits(string part) + { + foreach (var c in part) + { + if (!char.IsDigit(c)) return false; + } + return true; + } + + /// + /// Validates that a string matches the Unicode extension value pattern: (3*8alphanum) *("-" (3*8alphanum)) + /// Only ASCII alphanumeric characters are allowed. + /// + internal static bool IsValidUnicodeExtensionValue(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + var parts = value.Split('-'); + foreach (var part in parts) + { + // Each segment must be 3-8 ASCII alphanumeric characters + if (part.Length < 3 || part.Length > 8) + { + return false; + } + + foreach (var c in part) + { + // Must be ASCII alphanumeric only (a-z, A-Z, 0-9) + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) + { + return false; + } + } + } + + return true; + } + + /// + /// https://tc39.es/ecma402/#sec-canonicalizeunicodelocaleid + /// Canonicalizes a Unicode locale identifier. + /// + private static string CanonicalizeUnicodeLocaleId(string locale) + { + // 1. Check grandfathered tags first (highest priority) + // Use LocaleData first, then fallback to hardcoded dictionary + if (LocaleData.TagMappings.TryGetValue(locale, out var grandfatheredReplacement)) + { + return grandfatheredReplacement; + } + + if (GrandfatheredTags.TryGetValue(locale, out grandfatheredReplacement)) + { + return grandfatheredReplacement; + } + + // 2. Parse the locale into components + var parsed = ParseLanguageTag(locale); + + // 3. Apply language aliasing + if (parsed.Language != null) + { + // First try complex language mappings (may add script/region) + if (LocaleData.ComplexLanguageMappings.TryGetValue(parsed.Language, out var complexMapping)) + { + parsed.Language = complexMapping.Language; + if (parsed.Script == null && complexMapping.Script != null) + { + parsed.Script = complexMapping.Script; + } + + if (parsed.Region == null && complexMapping.Region != null) + { + parsed.Region = complexMapping.Region; + } + } + // Then try simple language mappings from LocaleData + else if (LocaleData.LanguageMappings.TryGetValue(parsed.Language, out var langReplacement)) + { + parsed.Language = langReplacement; + } + // Fallback to hardcoded aliases + else if (LanguageAliases.TryGetValue(parsed.Language, out langReplacement)) + { + // Handle complex replacements like "sh" -> "sr-Latn" + if (langReplacement.Contains('-')) + { + var replacementParts = langReplacement.Split('-'); + parsed.Language = replacementParts[0]; + // Add script from replacement if not already present + if (replacementParts.Length > 1 && parsed.Script == null) + { + parsed.Script = replacementParts[1]; + } + } + else + { + parsed.Language = langReplacement; + } + } + } + + // 4. Apply region aliasing (LocaleData first, then fallback) + if (parsed.Region != null) + { + if (LocaleData.RegionMappings.TryGetValue(parsed.Region, out var regionReplacement)) + { + parsed.Region = regionReplacement; + } + else if (RegionAliases.TryGetValue(parsed.Region, out regionReplacement)) + { + parsed.Region = regionReplacement; + } + } + + // 5. Apply variant aliasing from CLDR data + if (parsed.Variants != null && parsed.Variants.Count > 0) + { + for (var i = 0; i < parsed.Variants.Count; i++) + { + if (LocaleData.VariantMappings.TryGetValue(parsed.Variants[i], out var variantMapping)) + { + parsed.Variants[i] = variantMapping.Replacement; + } + } + } + + // 6. Sort variant subtags alphabetically (per ECMA-402) + if (parsed.Variants != null && parsed.Variants.Count > 1) + { + parsed.Variants.Sort(StringComparer.OrdinalIgnoreCase); + } + + // 7. Canonicalize extensions + CanonicalizeExtensions(parsed); + + // 8. Build canonical tag + return BuildCanonicalTag(parsed); + } + + /// + /// Parses a BCP 47 language tag into its components. + /// + private static ParsedLanguageTag ParseLanguageTag(string tag) + { + var result = new ParsedLanguageTag(); + var parts = tag.Split('-'); + var index = 0; + + if (parts.Length == 0) + { + return result; + } + + // Language subtag (first part) + result.Language = parts[index++].ToLowerInvariant(); + + // Subsequent parts + while (index < parts.Length) + { + var part = parts[index]; + var partLower = part.ToLowerInvariant(); + + // Check for singleton (extension indicator) + if (part.Length == 1) + { + // Start of extension sequence + var extensionType = partLower[0]; + var extensionParts = new List { partLower }; + index++; + + if (extensionType == 'x') + { + // Private use extension: collect ALL remaining parts + while (index < parts.Length) + { + extensionParts.Add(parts[index].ToLowerInvariant()); + index++; + } + } + else + { + // Other extensions: collect until next singleton or end + while (index < parts.Length && parts[index].Length != 1) + { + extensionParts.Add(parts[index].ToLowerInvariant()); + index++; + } + } + + result.Extensions ??= new List(); + result.Extensions.Add(new ExtensionSubtag { Type = extensionType, Parts = extensionParts }); + } + else if (part.Length == 4 && char.IsLetter(part[0]) && result.Script == null && result.Region == null && (result.Variants == null || result.Variants.Count == 0)) + { + // Script subtag (4 letters, title case) + result.Script = char.ToUpperInvariant(part[0]) + partLower.Substring(1); + index++; + } + else if ((part.Length == 2 && char.IsLetter(part[0])) || (part.Length == 3 && char.IsDigit(part[0]))) + { + // Region subtag (2 letters uppercase or 3 digits) + if (result.Region == null && (result.Variants == null || result.Variants.Count == 0)) + { + result.Region = part.Length == 2 ? part.ToUpperInvariant() : part; + index++; + } + else + { + // It's a variant + result.Variants ??= new List(); + result.Variants.Add(partLower); + index++; + } + } + else + { + // Variant subtag (5-8 alphanumeric, 4 starting with digit, or unknown) + result.Variants ??= new List(); + result.Variants.Add(partLower); + index++; + } + } + + return result; + } + + /// + /// Canonicalizes extensions (u, t, x, etc.). + /// + private static void CanonicalizeExtensions(ParsedLanguageTag parsed) + { + if (parsed.Extensions == null) + { + return; + } + + for (var i = 0; i < parsed.Extensions.Count; i++) + { + var ext = parsed.Extensions[i]; + var type = ext.Type; + var parts = ext.Parts; + + if (type == 't' && parts.Count > 1) + { + // T extension: canonicalize tlang and tfield subtags + var newParts = new List { "t" }; + var tfields = new List(); + string? currentKey = null; + var currentValues = new List(); + var tlangParts = new List(); + var inTlang = true; + + for (var j = 1; j < parts.Count; j++) + { + var part = parts[j]; + + // tkey is exactly 2 chars, first is alpha, second is digit + if (part.Length == 2 && char.IsLetter(part[0]) && char.IsDigit(part[1])) + { + // This is a tkey + inTlang = false; + + if (currentKey != null) + { + tfields.Add(new KeyValueParts { Key = currentKey, Values = currentValues }); + currentValues = new List(); + } + currentKey = part; + } + else if (inTlang) + { + // Part of tlang + tlangParts.Add(part); + } + else + { + // Part of tvalue - apply value aliasing + if (TValueAliases.TryGetValue(part, out var alias)) + { + currentValues.Add(alias); + } + else + { + currentValues.Add(part); + } + } + } + + // Save last tfield + if (currentKey != null) + { + tfields.Add(new KeyValueParts { Key = currentKey, Values = currentValues }); + } + + // Canonicalize tlang if present + if (tlangParts.Count > 0) + { + // Apply language aliasing to tlang (try LocaleData first, then fallback) + if (LocaleData.LanguageMappings.TryGetValue(tlangParts[0], out var tlangReplacement)) + { + tlangParts[0] = tlangReplacement; + } + else if (LanguageAliases.TryGetValue(tlangParts[0], out tlangReplacement)) + { + tlangParts[0] = tlangReplacement; + } + + // Parse tlang structure: language[-script][-region][-variant]* + // and sort variant subtags alphabetically + var tlangPrefix = new List(); + var tlangVariants = new List(); + + for (var k = 0; k < tlangParts.Count; k++) + { + var part = tlangParts[k]; + if (k == 0) + { + // Language subtag + tlangPrefix.Add(part); + } + else if (part.Length == 4 && char.IsLetter(part[0]) && tlangVariants.Count == 0) + { + // Script subtag (4 letters before any variants) + tlangPrefix.Add(part); + } + else if ((part.Length == 2 && char.IsLetter(part[0])) || (part.Length == 3 && char.IsDigit(part[0]))) + { + // Region subtag (2 alpha or 3 digit) + if (tlangVariants.Count == 0) + { + tlangPrefix.Add(part); + } + else + { + // Treat as variant if we already have variants + tlangVariants.Add(part); + } + } + else + { + // Variant subtag + tlangVariants.Add(part); + } + } + + // Sort variants alphabetically + tlangVariants.Sort(StringComparer.Ordinal); + + // Add prefix parts + foreach (var p in tlangPrefix) + { + newParts.Add(p); + } + + // Add sorted variants + foreach (var v in tlangVariants) + { + newParts.Add(v); + } + } + + // Sort tfields alphabetically by tkey + tfields.Sort((a, b) => string.Compare(a.Key, b.Key, StringComparison.Ordinal)); + + // Add sorted tfields + foreach (var kv in tfields) + { + newParts.Add(kv.Key); + newParts.AddRange(kv.Values); + } + + parsed.Extensions[i] = new ExtensionSubtag { Type = type, Parts = newParts }; + } + else if (type == 'u') + { + // U extension: sort keywords alphabetically + var newParts = new List { "u" }; + var attributes = new List(); + var keywords = new List(); + string? currentKey = null; + var currentValues = new List(); + + for (var j = 1; j < parts.Count; j++) + { + var part = parts[j]; + + // ukey is exactly 2 chars, both alpha + if (part.Length == 2 && char.IsLetter(part[0]) && char.IsLetter(part[1]) && currentKey == null && keywords.Count == 0 && attributes.Count == 0 && j == 1) + { + // Could be first keyword key + currentKey = part; + } + else if (part.Length == 2 && char.IsLetter(part[0]) && char.IsLetter(part[1])) + { + // This is a ukey + if (currentKey != null) + { + keywords.Add(new KeyValueParts { Key = currentKey, Values = currentValues }); + currentValues = new List(); + } + currentKey = part; + } + else if (currentKey == null) + { + // Attribute (before any keywords) + attributes.Add(part); + } + else + { + // Part of uvalue + currentValues.Add(part); + } + } + + // Save last keyword + if (currentKey != null) + { + keywords.Add(new KeyValueParts { Key = currentKey, Values = currentValues }); + } + + // Apply Unicode value aliasing from CLDR data + foreach (var kw in keywords) + { + if (LocaleData.UnicodeMappings.TryGetValue(kw.Key, out var valueAliases)) + { + // First try looking up the full joined value (for hyphenated aliases like "ethiopic-amete-alem") + var fullValue = string.Join("-", kw.Values); + if (valueAliases.TryGetValue(fullValue, out var aliasedValue)) + { + // Replace all parts with the new value (may also be hyphenated) + kw.Values.Clear(); + foreach (var part in aliasedValue.Split('-')) + { + kw.Values.Add(part); + } + } + else + { + // Fall back to looking up individual parts + for (var k = 0; k < kw.Values.Count; k++) + { + if (valueAliases.TryGetValue(kw.Values[k], out aliasedValue)) + { + kw.Values[k] = aliasedValue; + } + } + } + } + + // Per UTS 35 §3.2.1: Any type value "true" is removed + // This means if the only value is "true", just keep the key + kw.Values.RemoveAll(v => string.Equals(v, "true", StringComparison.OrdinalIgnoreCase)); + } + + // Add sorted attributes + attributes.Sort(StringComparer.Ordinal); + newParts.AddRange(attributes); + + // Sort keywords alphabetically by key + keywords.Sort((a, b) => string.Compare(a.Key, b.Key, StringComparison.Ordinal)); + + // Add sorted keywords + foreach (var kv in keywords) + { + newParts.Add(kv.Key); + newParts.AddRange(kv.Values); + } + + parsed.Extensions[i] = new ExtensionSubtag { Type = type, Parts = newParts }; + } + } + + // Sort extensions by singleton (t before u before x, etc.) + parsed.Extensions.Sort((a, b) => a.Type.CompareTo(b.Type)); + } + + /// + /// Builds a canonical BCP 47 tag from parsed components. + /// + private static string BuildCanonicalTag(ParsedLanguageTag parsed) + { + var result = new List(); + + // Language + if (parsed.Language != null) + { + result.Add(parsed.Language); + } + + // Script + if (parsed.Script != null) + { + result.Add(parsed.Script); + } + + // Region + if (parsed.Region != null) + { + result.Add(parsed.Region); + } + + // Variants (already sorted) + if (parsed.Variants != null) + { + result.AddRange(parsed.Variants); + } + + // Extensions (already sorted) + if (parsed.Extensions != null) + { + foreach (var ext in parsed.Extensions) + { + result.AddRange(ext.Parts); + } + } + + return string.Join("-", result); + } + + private sealed class ParsedLanguageTag + { + public string? Language { get; set; } + public string? Script { get; set; } + public string? Region { get; set; } + public List? Variants { get; set; } + public List? Extensions { get; set; } + } + + private sealed class ExtensionSubtag + { + public char Type { get; set; } + public List Parts { get; set; } = new List(); + } + + private sealed class KeyValueParts + { + public string Key { get; set; } = ""; + public List Values { get; set; } = new List(); + } + + /// + /// Manually canonicalize a language tag according to BCP 47 rules. + /// + private static string CanonicalizeLanguageTag(string tag) + { + var parts = tag.Split('-'); + if (parts.Length == 0) + { + return tag; + } + + var result = new List(); + + // Language subtag (first part) - lowercase + result.Add(parts[0].ToLowerInvariant()); + + for (var i = 1; i < parts.Length; i++) + { + var part = parts[i]; + + if (part.Length == 4 && char.IsLetter(part[0])) + { + // Script subtag - title case + result.Add(char.ToUpperInvariant(part[0]) + part.Substring(1).ToLowerInvariant()); + } + else if (part.Length == 2 && char.IsLetter(part[0])) + { + // Region subtag (2 letters) - uppercase + result.Add(part.ToUpperInvariant()); + } + else if (part.Length == 3 && char.IsDigit(part[0])) + { + // Region subtag (3 digits) - as is + result.Add(part); + } + else + { + // Singleton, variant, or extension subtag - lowercase + result.Add(part.ToLowerInvariant()); + } + } + + return string.Join("-", result); + } + + /// + /// https://tc39.es/ecma402/#sec-resolvelocale + /// + internal static ResolvedLocale ResolveLocale( + Engine engine, + IReadOnlyCollection availableLocales, + List requestedLocales, + JsValue options, + string[] relevantExtensionKeys) + { + // 1. Let matcher be options.[[localeMatcher]]. + var matcher = options.IsObject() ? options.Get("localeMatcher").ToString() : "best fit"; + return ResolveLocaleCore(engine, availableLocales, requestedLocales, matcher, relevantExtensionKeys); + } + + /// + /// https://tc39.es/ecma402/#sec-resolvelocale + /// Overload that accepts pre-read localeMatcher to avoid reading the option twice. + /// + internal static ResolvedLocale ResolveLocale( + Engine engine, + IReadOnlyCollection availableLocales, + List requestedLocales, + string localeMatcher, + string[] relevantExtensionKeys) + { + return ResolveLocaleCore(engine, availableLocales, requestedLocales, localeMatcher, relevantExtensionKeys); + } + + private static ResolvedLocale ResolveLocaleCore( + Engine engine, + IReadOnlyCollection availableLocales, + List requestedLocales, + string matcher, + string[] relevantExtensionKeys) + { + + // 2. If matcher is "lookup", let r be LookupMatcher(availableLocales, requestedLocales). + // 3. Else let r be BestFitMatcher(availableLocales, requestedLocales). + var matcherResult = string.Equals(matcher, "lookup", StringComparison.Ordinal) + ? LookupMatcher(engine, availableLocales, requestedLocales) + : BestFitMatcher(engine, availableLocales, requestedLocales); + + // 4. Let foundLocale be r.[[locale]]. + var foundLocale = matcherResult.Locale; + + // For now, return a simplified result + // Full implementation would process extension keys + return new ResolvedLocale( + foundLocale, + foundLocale, // dataLocale + null // numberingSystem + ); + } + + /// + /// https://tc39.es/ecma402/#sec-lookupmatcher + /// + internal static MatcherResult LookupMatcher(Engine engine, IReadOnlyCollection availableLocales, List requestedLocales) + { + // 1. For each element locale of requestedLocales, do + foreach (var locale in requestedLocales) + { + // a. Let noExtensionsLocale be the String value that is locale with any Unicode locale extension sequences removed. + var noExtensionsLocale = RemoveUnicodeExtensions(locale); + + // b. Let availableLocale be BestAvailableLocale(availableLocales, noExtensionsLocale). + var availableLocale = BestAvailableLocale(availableLocales, noExtensionsLocale); + + // c. If availableLocale is not undefined, return the Record { [[locale]]: availableLocale, [[extension]]: extension }. + if (availableLocale != null) + { + return new MatcherResult(availableLocale, ExtractUnicodeExtension(locale)); + } + } + + // 2. Return the Record { [[locale]]: defaultLocale }. + return new MatcherResult(DefaultLocale(engine), null); + } + + /// + /// https://tc39.es/ecma402/#sec-bestfitmatcher + /// For now, this delegates to LookupMatcher. A proper implementation would use more sophisticated matching. + /// + internal static MatcherResult BestFitMatcher(Engine engine, IReadOnlyCollection availableLocales, List requestedLocales) + { + // For now, use the same algorithm as LookupMatcher + // A production implementation would use locale distance algorithms + return LookupMatcher(engine, availableLocales, requestedLocales); + } + + /// + /// https://tc39.es/ecma402/#sec-bestavailablelocale + /// + internal static string? BestAvailableLocale(IReadOnlyCollection availableLocales, string locale) + { + // 1. Let candidate be locale. + var candidate = locale; + + // 2. Repeat + while (true) + { + // a. If availableLocales contains candidate, return candidate. + if (ContainsLocale(availableLocales, candidate)) + { + return candidate; + } + + // Also try matching via CultureInfo + var culture = GetCultureInfo(candidate); + if (culture != null) + { + var cultureName = culture.Name; + if (ContainsLocale(availableLocales, cultureName)) + { + return cultureName; + } + } + + // Try expanded script form for Chinese regions + // zh-TW → zh-Hant-TW, zh-HK → zh-Hant-HK, zh-CN → zh-Hans-CN, etc. + if (candidate.StartsWith("zh-", StringComparison.OrdinalIgnoreCase) && + !candidate.Contains("-Hant", StringComparison.OrdinalIgnoreCase) && + !candidate.Contains("-Hans", StringComparison.OrdinalIgnoreCase)) + { + var region = candidate.Substring(3); + var isTraditional = string.Equals(region, "TW", StringComparison.OrdinalIgnoreCase) || + string.Equals(region, "HK", StringComparison.OrdinalIgnoreCase) || + string.Equals(region, "MO", StringComparison.OrdinalIgnoreCase); + var script = isTraditional ? "Hant" : "Hans"; + var expandedCandidate = $"zh-{script}-{region}"; + if (ContainsLocale(availableLocales, expandedCandidate)) + { + return expandedCandidate; + } + } + + // b. Let pos be the character index of the last occurrence of "-" in candidate. + var pos = candidate.LastIndexOf('-'); + + // c. If pos is undefined, return undefined. + if (pos == -1) + { + return null; + } + + // d. If pos >= 2 and the character at index pos - 2 of candidate is "-", decrease pos by 2. + if (pos >= 2 && candidate[pos - 2] == '-') + { + pos -= 2; + } + + // e. Let candidate be the substring of candidate from position 0 to position pos. + candidate = candidate.Substring(0, pos); + } + } + + private static bool ContainsLocale(IReadOnlyCollection locales, string locale) + { + foreach (var l in locales) + { + if (string.Equals(l, locale, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Returns the default locale for the current environment. + /// Uses the engine's configured culture if available, otherwise falls back to system culture. + /// + internal static string DefaultLocale(Engine? engine = null) + { + var culture = engine?.Options.Culture ?? CultureInfo.CurrentCulture; + return string.IsNullOrEmpty(culture.Name) ? "en" : culture.Name; + } + + /// + /// Gets a set of available locales from .NET's CultureInfo. + /// + internal static HashSet GetAvailableLocales() + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var culture in CultureInfo.GetCultures(CultureTypes.AllCultures)) + { + if (!string.IsNullOrEmpty(culture.Name)) + { + result.Add(culture.Name); + } + } + + return result; + } + + /// + /// Removes Unicode locale extension sequences from a language tag. + /// + private static string RemoveUnicodeExtensions(string locale) + { + // Unicode extensions start with "-u-" + var extensionIndex = locale.IndexOf("-u-", StringComparison.OrdinalIgnoreCase); + if (extensionIndex == -1) + { + return locale; + } + + // Find end of extension (next singleton or end of string) + var endIndex = locale.Length; + for (var i = extensionIndex + 3; i < locale.Length - 1; i++) + { + if (locale[i] == '-' && i + 2 < locale.Length && locale[i + 2] == '-') + { + // Found another singleton + endIndex = i; + break; + } + } + + if (endIndex < locale.Length) + { +#if NET6_0_OR_GREATER + return string.Concat(locale.AsSpan(0, extensionIndex), locale.AsSpan(endIndex)); +#else + return locale.Substring(0, extensionIndex) + locale.Substring(endIndex); +#endif + } + + return locale.Substring(0, extensionIndex); + } + + /// + /// Extracts the Unicode extension from a locale tag. + /// + private static string? ExtractUnicodeExtension(string locale) + { + var extensionIndex = locale.IndexOf("-u-", StringComparison.OrdinalIgnoreCase); + if (extensionIndex == -1) + { + return null; + } + + var endIndex = locale.Length; + for (var i = extensionIndex + 3; i < locale.Length - 1; i++) + { + if (locale[i] == '-' && i + 2 < locale.Length && locale[i + 2] == '-') + { + endIndex = i; + break; + } + } + + return locale.Substring(extensionIndex + 1, endIndex - extensionIndex - 1); + } + + /// + /// Converts a BCP 47 language tag to a .NET CultureInfo. + /// Returns null if the tag cannot be mapped. + /// + internal static CultureInfo? GetCultureInfo(string locale) + { + if (string.IsNullOrEmpty(locale)) + { + return null; + } + + try + { + // Remove Unicode extensions before creating CultureInfo + var cultureTag = RemoveUnicodeExtensions(locale); + // Use new CultureInfo() instead of GetCultureInfo() to get correct locale-specific + // data. GetCultureInfo returns a cached read-only culture that may have different + // NumberFormatInfo patterns (e.g., CurrencyNegativePattern). + return new CultureInfo(cultureTag); + } + catch (CultureNotFoundException) + { + // Try parent locale (remove last subtag) + var hyphenIndex = locale.LastIndexOf('-'); + if (hyphenIndex > 0) + { + return GetCultureInfo(locale.Substring(0, hyphenIndex)); + } + + return null; + } + } + + /// + /// https://tc39.es/ecma402/#sec-coerceoptionstoobject + /// + internal static ObjectInstance CoerceOptionsToObject(Engine engine, JsValue options) + { + if (options.IsUndefined()) + { + return ObjectInstance.OrdinaryObjectCreate(engine, null); + } + + return TypeConverter.ToObject(engine.Realm, options); + } + + /// + /// https://tc39.es/ecma402/#sec-getoptionsobject + /// Stricter than CoerceOptionsToObject - throws TypeError for non-object values. + /// + internal static ObjectInstance GetOptionsObject(Engine engine, JsValue options) + { + // 1. If options is undefined, return OrdinaryObjectCreate(null). + if (options.IsUndefined()) + { + return ObjectInstance.OrdinaryObjectCreate(engine, null); + } + + // 2. If options is an Object, return options. + if (options.IsObject()) + { + return options.AsObject(); + } + + // 3. Throw a TypeError exception. + Throw.TypeError(engine.Realm, "Options must be an object or undefined"); + return null!; + } + + /// + /// https://tc39.es/ecma402/#sec-getoption + /// + internal static JsValue GetOption( + Engine engine, + JsValue options, + string property, + OptionType type, + JsValue[]? values, + JsValue fallback) + { + // 1. Let value be ? Get(options, property). + var value = options.Get(property); + + // 2. If value is undefined, return fallback. + if (value.IsUndefined()) + { + return fallback; + } + + // 3. Convert value based on type + switch (type) + { + case OptionType.Boolean: + value = TypeConverter.ToBoolean(value) ? JsBoolean.True : JsBoolean.False; + break; + + case OptionType.String: + value = TypeConverter.ToJsString(value); + break; + } + + // 4. If values is not empty and value is not in values, throw RangeError + if (values != null && values.Length > 0) + { + var found = false; + foreach (var v in values) + { + if (JsValue.SameValue(value, v) || string.Equals(value.ToString(), v.ToString(), StringComparison.Ordinal)) + { + found = true; + value = v; // Use the canonical value from the list + break; + } + } + + if (!found) + { + Throw.RangeError(engine.Realm, $"Invalid value '{value}' for option '{property}'"); + } + } + + return value; + } + + /// + /// https://tc39.es/ecma402/#sec-getbooleanoption + /// + internal static bool GetBooleanOption(Engine engine, JsValue options, string property, bool fallback) + { + var value = GetOption(engine, options, property, OptionType.Boolean, null, fallback ? JsBoolean.True : JsBoolean.False); + return TypeConverter.ToBoolean(value); + } + + /// + /// https://tc39.es/ecma402/#sec-getstringorbooleanoption + /// + internal static JsValue GetStringOrBooleanOption( + Engine engine, + JsValue options, + string property, + JsValue[]? values, + JsValue trueValue, + JsValue falsyValue, + JsValue fallback) + { + var value = options.Get(property); + + if (value.IsUndefined()) + { + return fallback; + } + + if (value.IsBoolean()) + { + return TypeConverter.ToBoolean(value) ? trueValue : falsyValue; + } + + var stringValue = TypeConverter.ToJsString(value); + + if (JsValue.SameValue(stringValue, new JsString("true")) || JsValue.SameValue(stringValue, new JsString("false"))) + { + return fallback; + } + + if (values != null && values.Length > 0) + { + var found = false; + foreach (var v in values) + { + if (JsValue.SameValue(v, stringValue)) + { + found = true; + break; + } + } + + if (!found) + { + Throw.RangeError(engine.Realm, $"Invalid value '{stringValue}' for option '{property}'"); + } + } + + return stringValue; + } + + internal enum OptionType + { + Boolean, + String + } + + internal record struct MatcherResult(string Locale, string? Extension); + + internal record struct ResolvedLocale(string Locale, string DataLocale, string? NumberingSystem); +} diff --git a/Jint/Native/Intl/JsLocale.cs b/Jint/Native/Intl/JsLocale.cs new file mode 100644 index 0000000000..060932da0a --- /dev/null +++ b/Jint/Native/Intl/JsLocale.cs @@ -0,0 +1,116 @@ +using System.Globalization; +using Jint.Native.Object; + +namespace Jint.Native.Intl; + +/// +/// https://tc39.es/ecma402/#locale-objects +/// Represents an Intl.Locale instance. +/// +internal sealed class JsLocale : ObjectInstance +{ + /// + /// The canonicalized BCP 47 language tag. + /// + internal string Locale { get; } + + /// + /// The base name without extensions. + /// + internal string BaseName { get; } + + /// + /// The language subtag. + /// + internal string Language { get; } + + /// + /// The script subtag, if present. + /// + internal string? Script { get; } + + /// + /// The region subtag, if present. + /// + internal string? Region { get; } + + /// + /// The calendar type from Unicode extension, if present. + /// + internal string? Calendar { get; } + + /// + /// The case first option from Unicode extension, if present. + /// + internal string? CaseFirst { get; } + + /// + /// The collation type from Unicode extension, if present. + /// + internal string? Collation { get; } + + /// + /// The hour cycle from Unicode extension, if present. + /// + internal string? HourCycle { get; } + + /// + /// The numbering system from Unicode extension, if present. + /// + internal string? NumberingSystem { get; } + + /// + /// The numeric option from Unicode extension, if present. + /// + internal bool? Numeric { get; } + + /// + /// The first day of week from Unicode extension (fw), if present. + /// + internal string? FirstDayOfWeek { get; } + + /// + /// The variant subtags, if present. + /// + internal string[] Variants { get; } + + /// + /// The associated .NET CultureInfo. + /// + internal CultureInfo CultureInfo { get; } + + internal JsLocale( + Engine engine, + ObjectInstance prototype, + string locale, + string baseName, + string language, + string? script, + string? region, + string[] variants, + string? calendar, + string? caseFirst, + string? collation, + string? hourCycle, + string? numberingSystem, + bool? numeric, + string? firstDayOfWeek, + CultureInfo cultureInfo) : base(engine) + { + _prototype = prototype; + Locale = locale; + BaseName = baseName; + Language = language; + Script = script; + Region = region; + Variants = variants; + Calendar = calendar; + CaseFirst = caseFirst; + Collation = collation; + HourCycle = hourCycle; + NumberingSystem = numberingSystem; + Numeric = numeric; + FirstDayOfWeek = firstDayOfWeek; + CultureInfo = cultureInfo; + } +} diff --git a/Jint/Native/Intl/LocaleConstructor.cs b/Jint/Native/Intl/LocaleConstructor.cs index e1d21552f9..a23fd2b455 100644 --- a/Jint/Native/Intl/LocaleConstructor.cs +++ b/Jint/Native/Intl/LocaleConstructor.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Jint.Native.Function; using Jint.Native.Object; using Jint.Runtime; @@ -11,6 +12,8 @@ namespace Jint.Native.Intl; internal sealed class LocaleConstructor : Constructor { private static readonly JsString _functionName = new("Locale"); + private static readonly HashSet? CaseFirstValues = ["upper", "lower", "false"]; + private static readonly HashSet? HourCycleValues = ["h11", "h12", "h23", "h24"]; public LocaleConstructor( Engine engine, @@ -20,14 +23,1250 @@ public LocaleConstructor( { _prototype = functionPrototype; PrototypeObject = new LocalePrototype(engine, realm, this, objectPrototype); - _length = new PropertyDescriptor(JsNumber.PositiveZero, PropertyFlag.Configurable); + _length = new PropertyDescriptor(1, PropertyFlag.Configurable); _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden); } - public LocalePrototype PrototypeObject { get; } + private LocalePrototype PrototypeObject { get; } + /// + /// https://tc39.es/ecma402/#sec-intl.locale + /// public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget) { - throw new NotImplementedException(); + if (newTarget.IsUndefined()) + { + Throw.TypeError(_realm, "Intl.Locale must be called with 'new'"); + } + + var tag = arguments.At(0); + var options = arguments.At(1); + + // 1. If tag is not a String and tag is not an Object, throw a TypeError. + if (!tag.IsString() && !tag.IsObject()) + { + Throw.TypeError(_realm, "First argument to Intl.Locale must be a string or Locale object"); + } + + string tagString; + + // 2. If Type(tag) is Object and tag has an [[InitializedLocale]] internal slot, then + if (tag is JsLocale existingLocale) + { + tagString = existingLocale.Locale; + } + else + { + // 3. Else, let tag be ? ToString(tag). + tagString = TypeConverter.ToString(tag); + } + + // 4. If ! IsStructurallyValidLanguageTag(tag) is false, throw a RangeError. + if (!IntlUtilities.IsStructurallyValidLanguageTag(tagString)) + { + Throw.RangeError(_realm, $"Invalid language tag: {tagString}"); + } + + // Check for grandfathered tags before parsing + tagString = CanonicalizeGrandfatheredTag(tagString); + + // 5. Let options be ? CoerceOptionsToObject(options). + var optionsObj = IntlUtilities.CoerceOptionsToObject(_engine, options); + + // Parse the tag + var parsedLocale = ParseLanguageTag(tagString); + + // Apply options - read in spec-defined order: + // 1. Language ID components: language, script, region, variants + // 2. Unicode extensions in key order: ca, co, fw, hc, kf, kn, nu + var language = GetLanguageOption(optionsObj, parsedLocale.Language); + var script = GetScriptOption(optionsObj, parsedLocale.Script); + var region = GetRegionOption(optionsObj, parsedLocale.Region); + var variants = GetVariantsOption(optionsObj, parsedLocale.Variants); + + // Check if the combination of language+variants forms a grandfathered tag + // e.g., "cel" + variants=["gaulish"] should become "xtg" + var combinedTag = BuildBaseName(language, script, region, variants); + var canonicalizedCombined = CanonicalizeGrandfatheredTag(combinedTag); + if (!string.Equals(combinedTag, canonicalizedCombined, StringComparison.Ordinal)) + { + // The combined tag was a grandfathered tag, re-parse it + var reparsed = ParseLanguageTag(canonicalizedCombined); + language = reparsed.Language; + script = reparsed.Script; + region = reparsed.Region; + variants = reparsed.Variants; + } + + // Apply language+variant mappings for grandfathered variants (e.g., art+lojban → jbo) + // This handles cases like "art-lojban-fonipa" which should become "jbo-fonipa" + ApplyLanguageVariantMappings(ref language, ref variants); + + // Apply variant aliasing (e.g., arevela → language:hy, aaland → region:AX) + ApplyVariantMappings(ref language, ref script, ref region, ref variants); + + var calendar = GetUnicodeExtensionOption(optionsObj, "calendar", parsedLocale.Calendar); + var collation = GetUnicodeExtensionOption(optionsObj, "collation", parsedLocale.Collation); + var firstDayOfWeek = GetFirstDayOfWeekOption(optionsObj, parsedLocale.FirstDayOfWeek); + var hourCycle = GetOptionString(optionsObj, "hourCycle", parsedLocale.HourCycle, HourCycleValues); + var caseFirst = GetOptionString(optionsObj, "caseFirst", parsedLocale.CaseFirst, CaseFirstValues); + var numericValue = IntlUtilities.GetOption(_engine, optionsObj, "numeric", IntlUtilities.OptionType.Boolean, null, JsValue.Undefined); + bool? numeric = numericValue.IsUndefined() ? parsedLocale.Numeric : TypeConverter.ToBoolean(numericValue); + var numberingSystem = GetUnicodeExtensionOption(optionsObj, "numberingSystem", parsedLocale.NumberingSystem); + + // Build the canonical locale string + var canonicalLocale = BuildLocaleString(language, script, region, variants, parsedLocale.Attributes, calendar, caseFirst, collation, firstDayOfWeek, hourCycle, numberingSystem, numeric, parsedLocale.OtherUnicodeExtensions, parsedLocale.OtherExtensions); + var baseName = BuildBaseName(language, script, region, variants); + + // Get CultureInfo (without variants for .NET compatibility) + var cultureBaseName = BuildBaseName(language, script, region); + var cultureInfo = IntlUtilities.GetCultureInfo(cultureBaseName) ?? CultureInfo.InvariantCulture; + + // Get prototype from newTarget (for cross-realm construction) + var proto = GetPrototypeFromConstructor(newTarget, static intrinsics => intrinsics.Locale.PrototypeObject); + + return new JsLocale( + _engine, + proto, + canonicalLocale, + baseName, + language!, + script, + region, + variants.ToArray(), + calendar, + caseFirst, + collation, + hourCycle, + numberingSystem, + numeric, + firstDayOfWeek, + cultureInfo); + } + + private string? GetOptionString(ObjectInstance options, string property, string? fallback, HashSet? allowedValues = null) + { + var value = options.Get(property); + if (value.IsUndefined()) + { + return fallback; + } + + var stringValue = TypeConverter.ToString(value); + + if (allowedValues != null && allowedValues.Count > 0) + { + if (!allowedValues.Contains(stringValue)) + { + Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option '{property}'"); + } + } + + return stringValue; + } + + /// + /// Gets a Unicode extension option value and validates it matches the pattern (3*8alphanum) *("-" (3*8alphanum)) + /// + private string? GetUnicodeExtensionOption(ObjectInstance options, string property, string? fallback) + { + var value = options.Get(property); + if (value.IsUndefined()) + { + // Also canonicalize the fallback value (from parsed tag) + if (fallback != null) + { + return CanonicalizeUnicodeExtensionValue(property, fallback); + } + return fallback; + } + + var stringValue = TypeConverter.ToString(value); + + // Validate against pattern: (3*8alphanum) *("-" (3*8alphanum)) + if (!IsValidUnicodeExtensionValue(stringValue)) + { + Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option '{property}'"); + } + + // Canonicalize using Unicode mappings (e.g., "islamicc" → "islamic-civil") + stringValue = CanonicalizeUnicodeExtensionValue(property, stringValue); + + return stringValue; + } + + /// + /// Canonicalizes a Unicode extension value using CLDR data. + /// Maps the property name to its Unicode extension key (e.g., "calendar" → "ca"). + /// + private static string CanonicalizeUnicodeExtensionValue(string property, string value) + { + // Map property names to Unicode extension keys + var unicodeKey = property switch + { + "calendar" => "ca", + "collation" => "co", + "numberingSystem" => "nu", + _ => null + }; + + if (unicodeKey == null) + { + return value; + } + + // Look up the mapping in UnicodeMappings + if (Data.LocaleData.UnicodeMappings.TryGetValue(unicodeKey, out var mappings)) + { + if (mappings.TryGetValue(value, out var canonicalValue)) + { + return canonicalValue; + } + } + + return value; + } + + /// + /// Validates that a string matches the Unicode extension value pattern: (3*8alphanum) *("-" (3*8alphanum)) + /// + private static bool IsValidUnicodeExtensionValue(string value) + { + return IntlUtilities.IsValidUnicodeExtensionValue(value); + } + + /// + /// Gets and validates the language option. + /// Language must be 2-3 letters, 4 letters (reserved), or 5-8 letters. + /// + private string? GetLanguageOption(ObjectInstance options, string? fallback) + { + var value = options.Get("language"); + if (value.IsUndefined()) + { + return fallback; + } + + var stringValue = TypeConverter.ToString(value); + + // Validate language production: + // language = 2*3ALPHA / 4ALPHA / 5*8ALPHA + // Must be pure ASCII letters only, no hyphens + if (!IsValidLanguageSubtag(stringValue)) + { + Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option 'language'"); + } + + return CanonicalizeLanguage(stringValue.ToLowerInvariant()); + } + + /// + /// Gets and validates the region option. + /// Region must be 2 letters or 3 digits. + /// Applies region aliasing (e.g., 554 → NZ). + /// + private string? GetRegionOption(ObjectInstance options, string? fallback) + { + var value = options.Get("region"); + if (value.IsUndefined()) + { + return fallback; + } + + var stringValue = TypeConverter.ToString(value); + + // Validate region production: 2ALPHA / 3DIGIT + if (!IsValidRegionSubtag(stringValue)) + { + Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option 'region'"); + } + + var region = stringValue.ToUpperInvariant(); + + // Apply region aliasing (e.g., 554 → NZ) + if (Data.LocaleData.RegionMappings.TryGetValue(region, out var replacement)) + { + region = replacement; + } + + return region; + } + + /// + /// Gets and validates the script option. + /// Script must be exactly 4 letters. + /// + private string? GetScriptOption(ObjectInstance options, string? fallback) + { + var value = options.Get("script"); + if (value.IsUndefined()) + { + return fallback; + } + + var stringValue = TypeConverter.ToString(value); + + // Validate script production: 4ALPHA + if (!IsValidScriptSubtag(stringValue)) + { + Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option 'script'"); + } + + // Title case: first letter uppercase, rest lowercase + return char.ToUpperInvariant(stringValue[0]) + stringValue.Substring(1).ToLowerInvariant(); + } + + /// + /// Validates a language subtag per UTS 35: 2-3 letters or 5-8 letters. + /// 4-letter subtags are reserved and NOT valid for the language option. + /// "root" is not a valid Unicode BCP 47 language subtag. + /// + private static bool IsValidLanguageSubtag(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + // Per UTS 35: unicode_language_subtag = alpha{2,3} | alpha{5,8} + // 4-letter subtags are reserved and not valid + if (value.Length < 2 || value.Length == 4 || value.Length > 8) + { + return false; + } + + // Must be all ASCII letters (no hyphens, digits, etc.) + foreach (var c in value) + { + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'))) + { + return false; + } + } + + // "root" is not a valid Unicode BCP 47 language subtag + if (string.Equals(value, "root", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } + + /// + /// Validates a region subtag: 2 letters or 3 digits. + /// + private static bool IsValidRegionSubtag(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + // 2 letters + if (value.Length == 2) + { + return ((value[0] >= 'a' && value[0] <= 'z') || (value[0] >= 'A' && value[0] <= 'Z')) && + ((value[1] >= 'a' && value[1] <= 'z') || (value[1] >= 'A' && value[1] <= 'Z')); + } + + // 3 digits + if (value.Length == 3) + { + return value[0] >= '0' && value[0] <= '9' && + value[1] >= '0' && value[1] <= '9' && + value[2] >= '0' && value[2] <= '9'; + } + + return false; + } + + /// + /// Validates a script subtag: exactly 4 letters. + /// + private static bool IsValidScriptSubtag(string value) + { + if (string.IsNullOrEmpty(value) || value.Length != 4) + { + return false; + } + + foreach (var c in value) + { + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'))) + { + return false; + } + } + + return true; + } + + /// + /// Gets and validates the variants option. + /// The option value is a string of hyphen-separated variant subtags. + /// Variants are sorted alphabetically for canonicalization. + /// + private List GetVariantsOption(ObjectInstance options, List fallback) + { + var value = options.Get("variants"); + if (value.IsUndefined()) + { + // Sort fallback variants for canonicalization + var sortedFallback = new List(fallback); + sortedFallback.Sort(StringComparer.Ordinal); + return sortedFallback; + } + + var stringValue = TypeConverter.ToString(value); + + // Empty string is invalid + if (string.IsNullOrEmpty(stringValue)) + { + Throw.RangeError(_realm, "Invalid variants option: empty string"); + } + + // Check for leading/trailing dashes + if (stringValue[0] == '-' || stringValue[stringValue.Length - 1] == '-') + { + Throw.RangeError(_realm, $"Invalid variants option: {stringValue}"); + } + + // Check for double dashes + if (stringValue.Contains("--")) + { + Throw.RangeError(_realm, $"Invalid variants option: {stringValue}"); + } + + // Parse and validate variants + var variants = new List(); + var seenVariants = new HashSet(StringComparer.OrdinalIgnoreCase); + var parts = stringValue.Split('-'); + + foreach (var part in parts) + { + if (string.IsNullOrEmpty(part)) + { + Throw.RangeError(_realm, $"Invalid variants option: {stringValue}"); + } + + var lowerPart = part.ToLowerInvariant(); + + // Validate variant: 5-8 alphanumeric or 4 chars starting with digit + if (!IsVariantSubtag(lowerPart)) + { + Throw.RangeError(_realm, $"Invalid variant subtag: {part}"); + } + + // Check for duplicates (case-insensitive) + if (seenVariants.Contains(lowerPart)) + { + Throw.RangeError(_realm, $"Duplicate variant subtag: {part}"); + } + + seenVariants.Add(lowerPart); + variants.Add(lowerPart); + } + + // Sort variants alphabetically for canonicalization + variants.Sort(StringComparer.Ordinal); + + return variants; + } + + /// + /// Gets and validates the firstDayOfWeek option. + /// Numeric values (0-7) are converted to weekday strings. + /// String values must be 3-8 alphanumeric characters (can be hyphenated). + /// + private string? GetFirstDayOfWeekOption(ObjectInstance options, string? fallback) + { + var value = options.Get("firstDayOfWeek"); + if (value.IsUndefined()) + { + return fallback; + } + + string stringValue; + + // Check if it's a boolean first (true means no value, like kn extension) + if (value.IsBoolean()) + { + if (TypeConverter.ToBoolean(value)) + { + // Boolean true means just "fw" with no value + return ""; + } + else + { + // Boolean false becomes "false" + stringValue = "false"; + } + } + // Check if it's a number + else if (value.IsNumber()) + { + var numValue = (int) TypeConverter.ToNumber(value); + stringValue = WeekdayToString(numValue); + } + else + { + stringValue = TypeConverter.ToString(value); + + // Try to parse as integer string + if (int.TryParse(stringValue, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var numValue) && numValue >= 0 && numValue <= 7) + { + stringValue = WeekdayToString(numValue); + } + } + + // Validate the string value matches type sequence pattern + // Pattern: (3*8alphanum) *("-" (3*8alphanum)) + if (!IsValidFirstDayOfWeekValue(stringValue)) + { + Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option 'firstDayOfWeek'"); + } + + return stringValue.ToLowerInvariant(); + } + + /// + /// Converts a weekday number to its string representation. + /// 0 and 7 = sun, 1 = mon, 2 = tue, 3 = wed, 4 = thu, 5 = fri, 6 = sat + /// + private static string WeekdayToString(int day) + { + return day switch + { + 1 => "mon", + 2 => "tue", + 3 => "wed", + 4 => "thu", + 5 => "fri", + 6 => "sat", + 0 or 7 => "sun", + _ => day.ToString(System.Globalization.CultureInfo.InvariantCulture) + }; + } + + /// + /// Validates that a string matches the firstDayOfWeek value pattern. + /// Pattern: (3*8alphanum) *("-" (3*8alphanum)) + /// Each part must be 3-8 alphanumeric characters. + /// + private static bool IsValidFirstDayOfWeekValue(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + var parts = value.Split('-'); + foreach (var part in parts) + { + if (part.Length < 3 || part.Length > 8) + { + return false; + } + + foreach (var c in part) + { + if (!char.IsLetterOrDigit(c)) + { + return false; + } + } + } + + return true; + } + + private static ParsedLocale ParseLanguageTag(string tag) + { + var result = new ParsedLocale(); + var parts = tag.Split('-'); + + if (parts.Length == 0) + { + return result; + } + + var index = 0; + + // Language (required, 2-3 or 4-8 letters) + if (index < parts.Length && parts[index].Length >= 2 && parts[index].Length <= 8 && IsAllLetters(parts[index])) + { + result.Language = CanonicalizeLanguage(parts[index].ToLowerInvariant()); + index++; + } + + // Script (optional, 4 letters) + if (index < parts.Length && parts[index].Length == 4 && IsAllLetters(parts[index])) + { + result.Script = char.ToUpperInvariant(parts[index][0]) + parts[index].Substring(1).ToLowerInvariant(); + index++; + } + + // Region (optional, 2 letters or 3 digits) + if (index < parts.Length && ((parts[index].Length == 2 && IsAllLetters(parts[index])) || + (parts[index].Length == 3 && IsAllDigits(parts[index])))) + { + var region = parts[index].ToUpperInvariant(); + + // Apply region aliasing (e.g., numeric codes like 554 → NZ) + if (Data.LocaleData.RegionMappings.TryGetValue(region, out var regionReplacement)) + { + region = regionReplacement; + } + + // Apply script-sensitive region aliasing (e.g., Armn + SU → AM) + if (result.Script != null) + { + var scriptRegionKey = result.Script + "+" + region; + if (Data.LocaleData.ScriptRegionMappings.TryGetValue(scriptRegionKey, out var scriptRegionReplacement)) + { + region = scriptRegionReplacement; + } + } + + result.Region = region; + index++; + } + + // Variants (optional, 5-8 alphanum or 4 chars starting with digit) + while (index < parts.Length && IsVariantSubtag(parts[index])) + { + result.Variants.Add(parts[index].ToLowerInvariant()); + index++; + } + + // Parse extensions + while (index < parts.Length) + { + if (parts[index].Length == 1) + { + var singleton = char.ToLowerInvariant(parts[index][0]); + if (singleton == 'u') + { + // Unicode extension + index++; + + // First, collect any attributes (3-8 alphanumeric parts before any 2-char key) + while (index < parts.Length && parts[index].Length >= 3 && parts[index].Length <= 8 && parts[index].Length != 1) + { + // If this is a 2-char part, it's a key, not an attribute + if (parts[index].Length == 2) + { + break; + } + result.Attributes.Add(parts[index].ToLowerInvariant()); + index++; + } + + // Then process key-value pairs + while (index < parts.Length && parts[index].Length != 1) + { + // Keys are exactly 2 characters + if (parts[index].Length != 2) + { + // Unexpected format - skip + index++; + continue; + } + + var key = parts[index].ToLowerInvariant(); + index++; + + // Check if this key has a value (next part is not a singleton and is 3+ chars for type values) + string? value = null; + if (index < parts.Length && parts[index].Length != 1 && parts[index].Length >= 3) + { + value = parts[index].ToLowerInvariant(); + index++; + } + + var handled = false; + switch (key) + { + case "ca": + // Calendar can have multi-part values (e.g., islamic-civil) + // Only use first occurrence (duplicate keys: first wins) + if (value != null) + { + var calendarValue = CollectMultiPartValue(parts, ref index, value); + if (result.Calendar == null) + { + result.Calendar = calendarValue; + } + handled = true; + } + break; + case "co": + // Collation can have multi-part values + // Only use first occurrence (duplicate keys: first wins) + if (value != null) + { + var collationValue = CollectMultiPartValue(parts, ref index, value); + if (result.Collation == null) + { + result.Collation = collationValue; + } + handled = true; + } + break; + case "fw": + // FirstDayOfWeek can have multi-part values (e.g., frank-yung-fong-tang) + // Only use first occurrence (duplicate keys: first wins) + if (value != null) + { + // Collect additional value parts + var fwParts = new List { value }; + while (index < parts.Length && parts[index].Length >= 3 && parts[index].Length <= 8 && parts[index].Length != 1) + { + // Check if this looks like a key (2 chars) or next extension + if (parts[index].Length == 2) + { + break; + } + fwParts.Add(parts[index].ToLowerInvariant()); + index++; + } + if (result.FirstDayOfWeek == null) + { + result.FirstDayOfWeek = string.Join("-", fwParts); + } + handled = true; + } + break; + case "hc": + // Only use first occurrence (duplicate keys: first wins) + if (value != null && result.HourCycle == null) + { + result.HourCycle = value; + } + handled = value != null; + break; + case "kf": + // Per UTS35, "true" value is canonicalized to empty string (just "kf") + // Only use first occurrence (duplicate keys: first wins) + if (result.CaseFirst == null) + { + if (value != null) + { + result.CaseFirst = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ? "" : value; + } + else + { + // kf without value means "true" which canonicalizes to empty string + result.CaseFirst = ""; + } + } + handled = true; + break; + case "kn": + // Only use first occurrence (duplicate keys: first wins) + if (!result.Numeric.HasValue) + { + if (value != null) + { + result.Numeric = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + else + { + result.Numeric = true; + } + } + handled = true; + break; + case "nu": + // Numbering system can have multi-part values + // Only use first occurrence (duplicate keys: first wins) + if (value != null) + { + var nuValue = CollectMultiPartValue(parts, ref index, value); + if (result.NumberingSystem == null) + { + result.NumberingSystem = nuValue; + } + handled = true; + } + break; + } + + // Store unhandled unicode extension key/value pairs + if (!handled) + { + if (value != null) + { + result.OtherUnicodeExtensions.Add(key + "-" + value); + } + else + { + result.OtherUnicodeExtensions.Add(key); + } + } + } + } + else + { + // Other extension (a-w, y) or private use (x) + index++; // Skip the singleton + + var extParts = new List(); + if (singleton == 'x') + { + // Private use extension: consume ALL remaining parts + // (in private use, single-char parts are data, not singletons) + while (index < parts.Length) + { + extParts.Add(parts[index].ToLowerInvariant()); + index++; + } + } + else + { + // Regular extension: collect until next singleton + while (index < parts.Length && parts[index].Length != 1) + { + extParts.Add(parts[index].ToLowerInvariant()); + index++; + } + } + + // Store this extension as (singleton, content) + result.OtherExtensions.Add(new ExtensionEntry(singleton, string.Join("-", extParts))); + } + } + else + { + index++; + } + } + + return result; + } + + private static bool IsAllLetters(string s) + { + foreach (var c in s) + { + if (!char.IsLetter(c)) + { + return false; + } + } + + return true; + } + + private static bool IsAllDigits(string s) + { + foreach (var c in s) + { + if (!char.IsDigit(c)) + { + return false; + } + } + + return true; + } + + /// + /// Checks if a string is a valid variant subtag. + /// Variant subtags are 5-8 alphanumerics OR 4 chars starting with a digit. + /// + private static bool IsVariantSubtag(string s) + { + if (string.IsNullOrEmpty(s)) + { + return false; + } + + // Single character is an extension singleton, not a variant + if (s.Length == 1) + { + return false; + } + + // 4 characters starting with digit + if (s.Length == 4 && char.IsDigit(s[0])) + { + return IsAllAlphanumeric(s); + } + + // 5-8 alphanumeric characters + if (s.Length >= 5 && s.Length <= 8) + { + return IsAllAlphanumeric(s); + } + + return false; + } + + private static bool IsAllAlphanumeric(string s) + { + foreach (var c in s) + { + if (!char.IsLetterOrDigit(c)) + { + return false; + } + } + + return true; + } + + /// + /// Gets the key (first 2-char subtag) from a Unicode extension part like "ca-gregory" or "kn". + /// + private static string GetUnicodeExtensionKey(string part) + { + var dashIndex = part.IndexOf('-'); + return dashIndex > 0 ? part.Substring(0, dashIndex) : part; + } + + /// + /// Collects multi-part Unicode extension values (e.g., "islamic-civil" for calendar). + /// Values are 3-8 alphanumeric characters, and 2-char parts indicate a new key. + /// + private static string CollectMultiPartValue(string[] parts, ref int index, string firstValue) + { + var valueParts = new List { firstValue }; + + while (index < parts.Length && parts[index].Length >= 3 && parts[index].Length <= 8) + { + // A 2-char part would be a new key, so stop + if (parts[index].Length == 2) + { + break; + } + // A 1-char part is a singleton (next extension), so stop + if (parts[index].Length == 1) + { + break; + } + valueParts.Add(parts[index].ToLowerInvariant()); + index++; + } + + return string.Join("-", valueParts); + } + + private static string BuildBaseName(string? language, string? script, string? region, List? variants = null) + { + var parts = new List(); + + if (!string.IsNullOrEmpty(language)) + { + parts.Add(language!); + } + + if (!string.IsNullOrEmpty(script)) + { + parts.Add(script!); + } + + if (!string.IsNullOrEmpty(region)) + { + parts.Add(region!); + } + + if (variants != null) + { + parts.AddRange(variants); + } + + return string.Join("-", parts); + } + + private static string BuildLocaleString( + string? language, + string? script, + string? region, + List? variants, + List? attributes, + string? calendar, + string? caseFirst, + string? collation, + string? firstDayOfWeek, + string? hourCycle, + string? numberingSystem, + bool? numeric, + List? otherUnicodeExtensions = null, + List? otherExtensions = null) + { + var baseName = BuildBaseName(language, script, region, variants); + + // Collect all extensions for sorting + var allExtensions = new List(); + + // Build Unicode extension content if any options are set + var unicodeExtParts = new List(); + + // Add sorted attributes first (they come before key-value pairs) + if (attributes != null && attributes.Count > 0) + { + var sortedAttributes = new List(attributes); + sortedAttributes.Sort(StringComparer.Ordinal); + unicodeExtParts.AddRange(sortedAttributes); + } + + // Add key-value pairs + if (!string.IsNullOrEmpty(calendar)) + { + unicodeExtParts.Add("ca-" + calendar); + } + + if (!string.IsNullOrEmpty(collation)) + { + unicodeExtParts.Add("co-" + collation); + } + + if (firstDayOfWeek != null) + { + if (firstDayOfWeek.Length == 0) + { + unicodeExtParts.Add("fw"); + } + else + { + unicodeExtParts.Add("fw-" + firstDayOfWeek); + } + } + + if (!string.IsNullOrEmpty(hourCycle)) + { + unicodeExtParts.Add("hc-" + hourCycle); + } + + if (caseFirst != null) + { + if (caseFirst.Length == 0) + { + unicodeExtParts.Add("kf"); + } + else + { + unicodeExtParts.Add("kf-" + caseFirst); + } + } + + if (numeric.HasValue) + { + if (numeric.Value) + { + unicodeExtParts.Add("kn"); + } + else + { + unicodeExtParts.Add("kn-false"); + } + } + + if (!string.IsNullOrEmpty(numberingSystem)) + { + unicodeExtParts.Add("nu-" + numberingSystem); + } + + // Add other unicode extensions that were not recognized + if (otherUnicodeExtensions != null && otherUnicodeExtensions.Count > 0) + { + unicodeExtParts.AddRange(otherUnicodeExtensions); + } + + // Sort Unicode extension key-value pairs alphabetically by key + var attrCount = (attributes?.Count ?? 0); + if (unicodeExtParts.Count > attrCount) + { + var keyValuePairs = unicodeExtParts.GetRange(attrCount, unicodeExtParts.Count - attrCount); + keyValuePairs.Sort((a, b) => + { + var keyA = GetUnicodeExtensionKey(a); + var keyB = GetUnicodeExtensionKey(b); + return string.Compare(keyA, keyB, StringComparison.Ordinal); + }); + unicodeExtParts.RemoveRange(attrCount, unicodeExtParts.Count - attrCount); + unicodeExtParts.AddRange(keyValuePairs); + } + + // Add the Unicode extension to the list if it has content + if (unicodeExtParts.Count > 0) + { + allExtensions.Add(new ExtensionEntry('u', string.Join("-", unicodeExtParts))); + } + + // Add other extensions + if (otherExtensions != null) + { + allExtensions.AddRange(otherExtensions); + } + + // Sort extensions: alphabetically by singleton, but 'x' (private use) always comes last + allExtensions.Sort((a, b) => + { + if (a.Singleton == 'x' && b.Singleton != 'x') return 1; + if (b.Singleton == 'x' && a.Singleton != 'x') return -1; + return a.Singleton.CompareTo(b.Singleton); + }); + + // Build the result + var result = baseName; + foreach (var ext in allExtensions) + { + result += "-" + ext.Singleton; + if (!string.IsNullOrEmpty(ext.Content)) + { + result += "-" + ext.Content; + } + } + + return result; + } + + private sealed class ParsedLocale + { + public string? Language { get; set; } + public string? Script { get; set; } + public string? Region { get; set; } + public List Variants { get; } = new(); + /// + /// Unicode extension attributes (3-8 alphanumeric subtags before any keys). + /// + public List Attributes { get; } = new(); + public string? Calendar { get; set; } + public string? CaseFirst { get; set; } + public string? Collation { get; set; } + public string? FirstDayOfWeek { get; set; } + public string? HourCycle { get; set; } + public string? NumberingSystem { get; set; } + public bool? Numeric { get; set; } + /// + /// Stores unrecognized unicode extension keys/values (e.g., "cu-eur", etc.) + /// + public List OtherUnicodeExtensions { get; } = new(); + /// + /// Stores other extensions as (singleton, content) pairs for sorting. + /// e.g., ('a', "bar"), ('x', "u-foo") + /// + public List OtherExtensions { get; } = new(); + } + + private readonly struct ExtensionEntry + { + public ExtensionEntry(char singleton, string content) + { + Singleton = singleton; + Content = content; + } + + public char Singleton { get; } + public string Content { get; } + } + + /// + /// Canonicalizes grandfathered tags using CLDR data. + /// + private static string CanonicalizeGrandfatheredTag(string tag) + { + if (Data.LocaleData.TagMappings.TryGetValue(tag, out var replacement)) + { + return replacement; + } + return tag; + } + + /// + /// Canonicalizes a language subtag using CLDR data. + /// + private static string CanonicalizeLanguage(string language) + { + // Check simple language mappings (e.g., "mo" → "ro", "cmn" → "zh") + if (Data.LocaleData.LanguageMappings.TryGetValue(language, out var replacement)) + { + return replacement; + } + return language; + } + + /// + /// Applies variant aliasing per CLDR variantAlias rules. + /// For example: + /// - arevela → changes language to "hy" (and removes the variant) + /// - aaland → changes region to "AX" (and removes the variant) + /// - heploc → changes to variant "alalc97" + /// + private static void ApplyVariantMappings(ref string? language, ref string? script, ref string? region, ref List variants) + { + if (variants.Count == 0) + { + return; + } + + var changed = false; + var newVariants = new List(variants.Count); + + foreach (var variant in variants) + { + if (Data.LocaleData.VariantMappings.TryGetValue(variant, out var mapping)) + { + changed = true; + switch (mapping.Type) + { + case "language": + language = mapping.Replacement; + // Variant is removed (not added to newVariants) + break; + case "region": + region = mapping.Replacement; + // Variant is removed (not added to newVariants) + break; + case "variant": + // Replace with the new variant name + newVariants.Add(mapping.Replacement); + break; + } + } + else + { + newVariants.Add(variant); + } + } + + if (changed) + { + // Sort variants again after substitutions + newVariants.Sort(StringComparer.Ordinal); + variants = newVariants; + } + } + + /// + /// Applies language+variant mappings for grandfathered variants. + /// For example, "art" + "lojban" → "jbo" (with "lojban" removed from variants). + /// + private static void ApplyLanguageVariantMappings(ref string? language, ref List variants) + { + if (language == null || variants.Count == 0) + { + return; + } + + // Check each variant to see if language+variant forms a grandfathered pattern + for (var i = 0; i < variants.Count; i++) + { + var key = language + "+" + variants[i]; + if (Data.LocaleData.LanguageVariantMappings.TryGetValue(key, out var newLanguage)) + { + // Found a match - update language and remove the variant + var newVariants = new List(variants); + newVariants.RemoveAt(i); + language = newLanguage; + variants = newVariants; + // Recursively check for more mappings (unlikely but spec-compliant) + ApplyLanguageVariantMappings(ref language, ref variants); + return; + } + } } } diff --git a/Jint/Native/Intl/LocalePrototype.cs b/Jint/Native/Intl/LocalePrototype.cs index 5567b94cd2..f79573c974 100644 --- a/Jint/Native/Intl/LocalePrototype.cs +++ b/Jint/Native/Intl/LocalePrototype.cs @@ -1,7 +1,9 @@ +using Jint.Native.Intl.Data; using Jint.Native.Object; using Jint.Native.Symbol; using Jint.Runtime; using Jint.Runtime.Descriptors; +using Jint.Runtime.Interop; namespace Jint.Native.Intl; @@ -23,16 +25,406 @@ public LocalePrototype(Engine engine, protected override void Initialize() { - var properties = new PropertyDictionary(2, checkExistingKeys: false) + const PropertyFlag LengthFlags = PropertyFlag.Configurable; + const PropertyFlag PropertyFlags = PropertyFlag.Writable | PropertyFlag.Configurable; + const PropertyFlag AccessorFlags = PropertyFlag.Configurable; + + var properties = new PropertyDictionary(11, checkExistingKeys: false) { - ["constructor"] = new PropertyDescriptor(_constructor, true, false, true), + ["constructor"] = new PropertyDescriptor(_constructor, PropertyFlag.NonEnumerable), + ["maximize"] = new PropertyDescriptor(new ClrFunction(Engine, "maximize", Maximize, 0, LengthFlags), PropertyFlags), + ["minimize"] = new PropertyDescriptor(new ClrFunction(Engine, "minimize", Minimize, 0, LengthFlags), PropertyFlags), + ["toString"] = new PropertyDescriptor(new ClrFunction(Engine, "toString", ToLocaleString, 0, LengthFlags), PropertyFlags), + ["getCalendars"] = new PropertyDescriptor(new ClrFunction(Engine, "getCalendars", GetCalendars, 0, LengthFlags), PropertyFlags), + ["getCollations"] = new PropertyDescriptor(new ClrFunction(Engine, "getCollations", GetCollations, 0, LengthFlags), PropertyFlags), + ["getHourCycles"] = new PropertyDescriptor(new ClrFunction(Engine, "getHourCycles", GetHourCycles, 0, LengthFlags), PropertyFlags), + ["getNumberingSystems"] = new PropertyDescriptor(new ClrFunction(Engine, "getNumberingSystems", GetNumberingSystems, 0, LengthFlags), PropertyFlags), + ["getTimeZones"] = new PropertyDescriptor(new ClrFunction(Engine, "getTimeZones", GetTimeZones, 0, LengthFlags), PropertyFlags), + ["getTextInfo"] = new PropertyDescriptor(new ClrFunction(Engine, "getTextInfo", GetTextInfo, 0, LengthFlags), PropertyFlags), + ["getWeekInfo"] = new PropertyDescriptor(new ClrFunction(Engine, "getWeekInfo", GetWeekInfo, 0, LengthFlags), PropertyFlags), }; SetProperties(properties); + // Accessor properties - accessor properties don't have writable attribute + SetAccessor("baseName", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get baseName", GetBaseName, 0, LengthFlags), + Undefined, + AccessorFlags)); + + SetAccessor("calendar", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get calendar", GetCalendar, 0, LengthFlags), + Undefined, + AccessorFlags)); + + SetAccessor("caseFirst", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get caseFirst", GetCaseFirst, 0, LengthFlags), + Undefined, + AccessorFlags)); + + SetAccessor("collation", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get collation", GetCollation, 0, LengthFlags), + Undefined, + AccessorFlags)); + + SetAccessor("hourCycle", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get hourCycle", GetHourCycle, 0, LengthFlags), + Undefined, + AccessorFlags)); + + SetAccessor("language", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get language", GetLanguage, 0, LengthFlags), + Undefined, + AccessorFlags)); + + SetAccessor("numberingSystem", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get numberingSystem", GetNumberingSystem, 0, LengthFlags), + Undefined, + AccessorFlags)); + + SetAccessor("numeric", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get numeric", GetNumeric, 0, LengthFlags), + Undefined, + AccessorFlags)); + + SetAccessor("region", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get region", GetRegion, 0, LengthFlags), + Undefined, + AccessorFlags)); + + SetAccessor("script", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get script", GetScript, 0, LengthFlags), + Undefined, + AccessorFlags)); + + SetAccessor("firstDayOfWeek", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get firstDayOfWeek", GetFirstDayOfWeek, 0, LengthFlags), + Undefined, + AccessorFlags)); + + SetAccessor("variants", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get variants", GetVariants, 0, LengthFlags), + Undefined, + AccessorFlags)); + var symbols = new SymbolDictionary(1) { [GlobalSymbolRegistry.ToStringTag] = new("Intl.Locale", PropertyFlag.Configurable) }; SetSymbols(symbols); } + + private void SetAccessor(string name, GetSetPropertyDescriptor descriptor) + { + SetProperty(name, descriptor); + } + + private JsLocale ValidateLocale(JsValue thisObject) + { + if (thisObject is JsLocale locale) + { + return locale; + } + + Throw.TypeError(_realm, "Value is not an Intl.Locale"); + return null!; // Never reached + } + + /// + /// https://tc39.es/ecma402/#sec-Intl.Locale.prototype.maximize + /// + private ObjectInstance Maximize(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + + // Use CLDR likely subtags algorithm + var maximizedName = LikelySubtags.AddLikelySubtags(locale.Locale); + + // Create new locale with maximized name + return _constructor.Construct([new JsString(maximizedName)], _constructor); + } + + /// + /// https://tc39.es/ecma402/#sec-Intl.Locale.prototype.minimize + /// + private ObjectInstance Minimize(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + + // Use CLDR likely subtags algorithm + var minimizedName = LikelySubtags.RemoveLikelySubtags(locale.Locale); + + return _constructor.Construct([new JsString(minimizedName)], _constructor); + } + + /// + /// https://tc39.es/ecma402/#sec-Intl.Locale.prototype.toString + /// + private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + return locale.Locale; + } + + private JsValue GetBaseName(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + return locale.BaseName; + } + + private JsValue GetCalendar(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + return locale.Calendar ?? Undefined; + } + + private JsValue GetCaseFirst(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + return locale.CaseFirst ?? Undefined; + } + + private JsValue GetCollation(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + return locale.Collation ?? Undefined; + } + + private JsValue GetHourCycle(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + return locale.HourCycle ?? Undefined; + } + + private JsValue GetLanguage(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + return locale.Language; + } + + private JsValue GetNumberingSystem(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + return locale.NumberingSystem ?? Undefined; + } + + private JsBoolean GetNumeric(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + return locale.Numeric.HasValue ? (locale.Numeric.Value ? JsBoolean.True : JsBoolean.False) : JsBoolean.False; + } + + private JsValue GetRegion(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + return locale.Region ?? Undefined; + } + + private JsValue GetScript(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + return locale.Script ?? Undefined; + } + + /// + /// https://tc39.es/ecma402/#sec-Intl.Locale.prototype.variants + /// Returns hyphen-separated variants string or undefined if no variants. + /// + private JsValue GetVariants(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + var variants = locale.Variants; + + if (variants.Length == 0) + { + return Undefined; + } + + return string.Join("-", variants); + } + + private JsValue GetFirstDayOfWeek(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + + // Return the firstDayOfWeek value from the locale if set + if (!string.IsNullOrEmpty(locale.FirstDayOfWeek)) + { + return locale.FirstDayOfWeek; + } + + // If not explicitly set, return undefined per spec + return Undefined; + } + + /// + /// https://tc39.es/ecma402/#sec-Intl.Locale.prototype.getCalendars + /// + private JsArray GetCalendars(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + + // Return array of supported calendars + // For .NET, we primarily support Gregorian calendar + var result = new JsArray(Engine, 1); + result.SetIndexValue(0, locale.Calendar ?? "gregory", updateLength: true); + return result; + } + + /// + /// https://tc39.es/ecma402/#sec-Intl.Locale.prototype.getCollations + /// + private JsArray GetCollations(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + + // Return array of supported collations + var result = new JsArray(Engine, 1); + result.SetIndexValue(0, locale.Collation ?? "default", updateLength: true); + return result; + } + + /// + /// https://tc39.es/ecma402/#sec-Intl.Locale.prototype.getHourCycles + /// + private JsArray GetHourCycles(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + var culture = locale.CultureInfo; + + // Determine hour cycle based on culture's time format + var timePattern = culture.DateTimeFormat.ShortTimePattern; + + // 24-hour format uses 'H', 12-hour format uses 'h' + var hourCycle = timePattern.Contains('H') ? "h23" : "h12"; + + var result = new JsArray(Engine, 1); + result.SetIndexValue(0, locale.HourCycle ?? hourCycle, updateLength: true); + return result; + } + + /// + /// https://tc39.es/ecma402/#sec-Intl.Locale.prototype.getNumberingSystems + /// + private JsArray GetNumberingSystems(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + + // Return array of numbering systems + // Most locales use "latn" (Latin digits 0-9) + var result = new JsArray(Engine, 1); + result.SetIndexValue(0, locale.NumberingSystem ?? "latn", updateLength: true); + return result; + } + + /// + /// https://tc39.es/ecma402/#sec-Intl.Locale.prototype.getTimeZones + /// + private JsValue GetTimeZones(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + + // TimeZones are only available for locales with a region + if (string.IsNullOrEmpty(locale.Region)) + { + return Undefined; + } + + // Return time zones for the region + // This is a simplified implementation + var result = new JsArray(Engine, 1); + + // Map common regions to their primary time zones + var timeZone = locale.Region?.ToUpperInvariant() switch + { + "US" => "America/New_York", + "GB" => "Europe/London", + "DE" => "Europe/Berlin", + "FR" => "Europe/Paris", + "JP" => "Asia/Tokyo", + "CN" => "Asia/Shanghai", + "AU" => "Australia/Sydney", + "IN" => "Asia/Kolkata", + "BR" => "America/Sao_Paulo", + "RU" => "Europe/Moscow", + _ => null + }; + + if (timeZone != null) + { + result.SetIndexValue(0, timeZone, updateLength: true); + } + + return result; + } + + /// + /// https://tc39.es/ecma402/#sec-Intl.Locale.prototype.getTextInfo + /// + private JsObject GetTextInfo(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + var culture = locale.CultureInfo; + + var result = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject); + + // Determine text direction + // RTL languages include Arabic, Hebrew, Persian, Urdu, etc. + var isRtl = culture.TextInfo.IsRightToLeft; + result.CreateDataPropertyOrThrow("direction", isRtl ? "rtl" : "ltr"); + + return result; + } + + /// + /// https://tc39.es/ecma402/#sec-Intl.Locale.prototype.getWeekInfo + /// + private JsObject GetWeekInfo(JsValue thisObject, JsCallArguments arguments) + { + var locale = ValidateLocale(thisObject); + var region = locale.Region; + + var result = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject); + + // First day of week (1=Monday, 7=Sunday) + // Use fw extension if present, otherwise from CLDR data + int firstDayNum; + if (locale.FirstDayOfWeek != null) + { + firstDayNum = ConvertDayNameToNumber(locale.FirstDayOfWeek); + } + else + { + firstDayNum = WeekData.GetFirstDayOfWeek(region); + } + result.CreateDataPropertyOrThrow("firstDay", firstDayNum); + + // Weekend days from CLDR data + var weekendDays = WeekData.GetWeekend(region); + var weekend = new JsArray(Engine, (uint) weekendDays.Length); + for (var i = 0; i < weekendDays.Length; i++) + { + weekend.SetIndexValue((uint) i, weekendDays[i], updateLength: true); + } + result.CreateDataPropertyOrThrow("weekend", weekend); + + return result; + } + + /// + /// Converts a day name abbreviation (mon, tue, wed, etc.) to a number (1-7). + /// + private static int ConvertDayNameToNumber(string dayName) + { + return dayName.ToLowerInvariant() switch + { + "mon" => 1, + "tue" => 2, + "wed" => 3, + "thu" => 4, + "fri" => 5, + "sat" => 6, + "sun" => 7, + _ => 1 // Default to Monday + }; + } }