From ab55bc798f4efea33c130d5e954740d7b9c47752 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sat, 24 Jan 2026 16:58:53 +0200 Subject: [PATCH] feat(intl): Add Intl.Collator and update String.localeCompare Implement locale-aware string comparison: - Intl.Collator with sensitivity and numeric options - String.prototype.localeCompare delegation to Collator - CompareInfo/CompareOptions caching for performance - Support for all collation options per ECMA-402 Register Collator in IntlInstance. Co-Authored-By: Claude Sonnet 4.5 --- Jint/Native/Intl/CollatorConstructor.cs | 377 +++++++++++++++++++++++- Jint/Native/Intl/CollatorPrototype.cs | 67 ++++- Jint/Native/Intl/IntlInstance.cs | 3 +- Jint/Native/Intl/JsCollator.cs | 186 ++++++++++++ Jint/Native/String/StringPrototype.cs | 18 +- 5 files changed, 639 insertions(+), 12 deletions(-) create mode 100644 Jint/Native/Intl/JsCollator.cs diff --git a/Jint/Native/Intl/CollatorConstructor.cs b/Jint/Native/Intl/CollatorConstructor.cs index f9984ea1ab..3c2e8b4a07 100644 --- a/Jint/Native/Intl/CollatorConstructor.cs +++ b/Jint/Native/Intl/CollatorConstructor.cs @@ -1,7 +1,9 @@ +using System.Globalization; using Jint.Native.Function; using Jint.Native.Object; using Jint.Runtime; using Jint.Runtime.Descriptors; +using Jint.Runtime.Interop; namespace Jint.Native.Intl; @@ -11,6 +13,17 @@ namespace Jint.Native.Intl; internal sealed class CollatorConstructor : Constructor { private static readonly JsString _functionName = new("Collator"); + private static readonly HashSet LocaleMatcherValues = ["lookup", "best fit"]; + private static readonly HashSet UsageValues = ["sort", "search"]; + private static readonly HashSet SensitivityValues = ["base", "accent", "case", "variant"]; + private static readonly HashSet CaseFirstValues = ["upper", "lower", "false"]; + + // Valid collation types per CLDR (excluding "standard" and "search" which are special) + private static readonly HashSet ValidCollationTypes = [ + "big5han", "compat", "dict", "direct", "ducet", "emoji", "eor", + "gb2312", "phonebk", "phonebook", "phonetic", "pinyin", "reformed", + "searchjl", "stroke", "trad", "unihan", "zhuyin", "default" + ]; public CollatorConstructor( Engine engine, @@ -24,10 +37,370 @@ public CollatorConstructor( _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden); } - public CollatorPrototype PrototypeObject { get; } + private CollatorPrototype PrototypeObject { get; } + + protected override void Initialize() + { + const PropertyFlag PropertyFlags = PropertyFlag.Configurable | PropertyFlag.Writable; + var properties = new PropertyDictionary(1, checkExistingKeys: false) + { + ["supportedLocalesOf"] = new(new ClrFunction(Engine, "supportedLocalesOf", SupportedLocalesOf, 1, PropertyFlag.Configurable), PropertyFlags) + }; + SetProperties(properties); + } + /// + /// Called when Intl.Collator is invoked without `new`. + /// + protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments) + { + return Construct(arguments, this); + } + + /// + /// https://tc39.es/ecma402/#sec-intl.collator + /// public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget) { - throw new NotImplementedException(); + var locales = arguments.At(0); + var options = arguments.At(1); + // (handled by runtime) + + // Get options object + var optionsObj = IntlUtilities.CoerceOptionsToObject(_engine, options); + + // Validate localeMatcher option first (must be done before other processing) + GetStringOption(optionsObj, "localeMatcher", LocaleMatcherValues, "best fit"); + + // Resolve locale + var requestedLocales = IntlUtilities.CanonicalizeLocaleList(_engine, locales); + var availableLocales = IntlUtilities.GetAvailableLocales(); + var resolvedLocale = ResolveCollatorLocale(_engine, availableLocales, requestedLocales, optionsObj); + + // Parse Unicode extensions from the first requested locale (if any) since resolved locale may strip them + string? uCollation = null; + bool? uNumeric = null; + string? uCaseFirst = null; + if (requestedLocales.Count > 0) + { + ParseUnicodeExtensions(requestedLocales[0], out uCollation, out uNumeric, out uCaseFirst); + } + + // Get options (options override unicode extensions) + var usage = GetStringOption(optionsObj, "usage", UsageValues, "sort"); + var sensitivity = GetSensitivity(optionsObj); + var collation = GetCollationOption(optionsObj, uCollation); + var numeric = GetNumericOption(optionsObj, uNumeric); + var caseFirst = GetCaseFirstOption(optionsObj, uCaseFirst); + + // Build locale with unicode extensions - include extension if it was present AND final value matches + var finalLocale = BuildLocaleWithExtensions(resolvedLocale, + collation, uCollation, + numeric, uNumeric, + caseFirst, uCaseFirst); + + // Get CompareInfo for the locale + var culture = IntlUtilities.GetCultureInfo(resolvedLocale) ?? CultureInfo.InvariantCulture; + + // Get ignorePunctuation with locale-specific default + // Thai (th) defaults to true, others default to false + var ignorePunctuationDefault = resolvedLocale.StartsWith("th", StringComparison.OrdinalIgnoreCase); + var ignorePunctuation = GetIgnorePunctuationOption(optionsObj, ignorePunctuationDefault); + var compareInfo = culture.CompareInfo; + + // Map sensitivity to CompareOptions + var compareOptions = MapSensitivityToCompareOptions(sensitivity, ignorePunctuation); + + // Get prototype from newTarget (for cross-realm construction) + var proto = GetPrototypeFromConstructor(newTarget, static intrinsics => intrinsics.Collator.PrototypeObject); + + return new JsCollator( + _engine, + proto, + finalLocale, + usage, + sensitivity, + ignorePunctuation, + collation, + numeric, + caseFirst, + compareInfo, + compareOptions); + } + + private static string BuildLocaleWithExtensions(string baseLocale, + string collation, string? uCollation, + bool numeric, bool? uNumeric, + string caseFirst, string? uCaseFirst) + { + // Include extension if: (1) extension was present in locale AND (2) final value equals extension value + var extensions = new List(); + + // Add collation extension if extension was present and final value matches + if (uCollation != null && + string.Equals(collation, uCollation, StringComparison.Ordinal) && + !string.Equals(collation, "default", StringComparison.Ordinal)) + { + extensions.Add("co-" + collation); + } + + // Add kn (numeric) extension if extension was present and final value matches extension value + // Per spec: canonical form is just "kn" for true, don't include for false (default) + if (uNumeric.HasValue && numeric == uNumeric.Value && numeric) + { + extensions.Add("kn"); + } + + // Add kf (caseFirst) extension if extension was present and final value matches + if (uCaseFirst != null && + string.Equals(caseFirst, uCaseFirst, StringComparison.Ordinal) && + !string.Equals(caseFirst, "false", StringComparison.Ordinal)) + { + extensions.Add("kf-" + caseFirst); + } + + if (extensions.Count == 0) + { + return baseLocale; + } + + // Sort extensions alphabetically (co, kf, kn order) + extensions.Sort(StringComparer.Ordinal); + return baseLocale + "-u-" + string.Join("-", extensions); + } + + private string GetStringOption(ObjectInstance options, string property, HashSet values, string fallback) + { + var value = options.Get(property); + if (value.IsUndefined()) + { + return fallback; + } + + var stringValue = TypeConverter.ToString(value); + + if (values != null && values.Count > 0) + { + if (!values.Contains(stringValue)) + { + Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option '{property}'"); + } + } + + return stringValue; + } + + private string GetSensitivity(ObjectInstance options) + { + var value = options.Get("sensitivity"); + if (value.IsUndefined()) + { + // Default depends on usage - "variant" for sort, "variant" for search + return "variant"; + } + + var stringValue = TypeConverter.ToString(value); + + if (!SensitivityValues.Contains(stringValue)) + { + Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option 'sensitivity'"); + } + + return stringValue; + } + + private static bool GetIgnorePunctuationOption(ObjectInstance options, bool fallback) + { + var value = options.Get("ignorePunctuation"); + if (value.IsUndefined()) + { + return fallback; + } + + return TypeConverter.ToBoolean(value); + } + + private static void ParseUnicodeExtensions(string locale, out string? collation, out bool? numeric, out string? caseFirst) + { + collation = null; + numeric = null; + caseFirst = null; + + var uIndex = locale.IndexOf("-u-", StringComparison.Ordinal); + if (uIndex < 0) + { + return; + } + + var parts = locale.Substring(uIndex + 3).Split('-'); + for (var i = 0; i < parts.Length; i++) + { + var key = parts[i]; + // Keys are exactly 2 characters, values are 3+ characters (or "true"/"false" which are special) + // If the next part is also 2 characters, it's another key, not a value + if (key.Length == 2) + { + // Check if there's a value (3+ chars or special 2-char values that aren't keys) + string? value = null; + if (i + 1 < parts.Length && parts[i + 1].Length >= 3) + { + value = parts[i + 1]; + i++; + } + + switch (key) + { + case "co": + collation = value; + break; + case "kn": + // -u-kn without value or with any non-false value means true + if (value == null) + { + numeric = true; + } + else + { + numeric = !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase); + } + break; + case "kf": + caseFirst = value; + break; + } + } + } + } + + private static string GetCollationOption(ObjectInstance options, string? unicodeExtension) + { + var value = options.Get("collation"); + if (!value.IsUndefined()) + { + var collation = TypeConverter.ToString(value); + + // Per ECMA-402: "standard" and "search" collations are explicitly disallowed + // They should fall back to "default" + if (string.Equals(collation, "standard", StringComparison.Ordinal) || + string.Equals(collation, "search", StringComparison.Ordinal)) + { + return "default"; + } + + // Validate against known collation types - ignore invalid values + if (!ValidCollationTypes.Contains(collation)) + { + return "default"; + } + + return collation; + } + + // Check unicode extension, but disallow "standard", "search", and invalid values + if (unicodeExtension != null && + !string.Equals(unicodeExtension, "standard", StringComparison.Ordinal) && + !string.Equals(unicodeExtension, "search", StringComparison.Ordinal) && + ValidCollationTypes.Contains(unicodeExtension)) + { + return unicodeExtension; + } + + return "default"; + } + + private static bool GetNumericOption(ObjectInstance options, bool? unicodeExtension) + { + var value = options.Get("numeric"); + if (!value.IsUndefined()) + { + return TypeConverter.ToBoolean(value); + } + + return unicodeExtension ?? false; + } + + private string GetCaseFirstOption(ObjectInstance options, string? unicodeExtension) + { + var value = options.Get("caseFirst"); + if (!value.IsUndefined()) + { + var stringValue = TypeConverter.ToString(value); + + if (!CaseFirstValues.Contains(stringValue)) + { + Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option 'caseFirst'"); + } + + return stringValue; + } + + if (unicodeExtension != null && CaseFirstValues.Contains(unicodeExtension)) + { + return unicodeExtension; + } + + return "false"; + } + + private static CompareOptions MapSensitivityToCompareOptions(string sensitivity, bool ignorePunctuation) + { + var options = sensitivity switch + { + "base" => + // Ignore case and accents + CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace, + "accent" => + // Ignore case but consider accents + CompareOptions.IgnoreCase, + "case" => + // Consider case but ignore accents + CompareOptions.IgnoreNonSpace, + "variant" => + // Consider both case and accents + CompareOptions.None, + _ => CompareOptions.None, + }; + + if (ignorePunctuation) + { + options |= CompareOptions.IgnoreSymbols; + } + + return options; + } + + private static string ResolveCollatorLocale(Engine engine, HashSet availableLocales, List requestedLocales, ObjectInstance options) + { + var resolved = IntlUtilities.ResolveLocale(engine, availableLocales, requestedLocales, options, []); + return resolved.Locale; + } + + /// + /// https://tc39.es/ecma402/#sec-intl.collator.supportedlocalesof + /// + private JsArray SupportedLocalesOf(JsValue thisObject, JsCallArguments arguments) + { + var locales = arguments.At(0); + var options = arguments.At(1); + + var requestedLocales = IntlUtilities.CanonicalizeLocaleList(_engine, locales); + var availableLocales = IntlUtilities.GetAvailableLocales(); + + // Validate localeMatcher option + var optionsObj = IntlUtilities.CoerceOptionsToObject(_engine, options); + GetStringOption(optionsObj, "localeMatcher", LocaleMatcherValues, "best fit"); + + // For now, return all requested locales that are available + List supported = []; + foreach (var locale in requestedLocales) + { + var bestAvailable = IntlUtilities.BestAvailableLocale(availableLocales, locale); + if (bestAvailable != null) + { + supported.Add(locale); + } + } + + return new JsArray(_engine, supported.ToArray()); } } diff --git a/Jint/Native/Intl/CollatorPrototype.cs b/Jint/Native/Intl/CollatorPrototype.cs index 064fbd2c50..bb4bac2d3e 100644 --- a/Jint/Native/Intl/CollatorPrototype.cs +++ b/Jint/Native/Intl/CollatorPrototype.cs @@ -2,6 +2,7 @@ using Jint.Native.Symbol; using Jint.Runtime; using Jint.Runtime.Descriptors; +using Jint.Runtime.Interop; namespace Jint.Native.Intl; @@ -23,16 +24,80 @@ public CollatorPrototype(Engine engine, protected override void Initialize() { + const PropertyFlag LengthFlags = PropertyFlag.Configurable; + const PropertyFlag PropertyFlags = PropertyFlag.Writable | PropertyFlag.Configurable; + var properties = new PropertyDictionary(2, checkExistingKeys: false) { - ["constructor"] = new PropertyDescriptor(_constructor, true, false, true), + ["constructor"] = new PropertyDescriptor(_constructor, PropertyFlag.NonEnumerable), + ["resolvedOptions"] = new PropertyDescriptor(new ClrFunction(Engine, "resolvedOptions", ResolvedOptions, 0, LengthFlags), PropertyFlags), }; SetProperties(properties); + // compare is an accessor property - accessor properties don't have writable attribute + SetAccessor("compare", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get compare", GetCompare, 0, LengthFlags), + Undefined, + PropertyFlag.Configurable)); + var symbols = new SymbolDictionary(1) { [GlobalSymbolRegistry.ToStringTag] = new("Intl.Collator", PropertyFlag.Configurable) }; SetSymbols(symbols); } + + private void SetAccessor(string name, GetSetPropertyDescriptor descriptor) + { + SetProperty(name, descriptor); + } + + private JsCollator ValidateCollator(JsValue thisObject) + { + if (thisObject is JsCollator collator) + { + return collator; + } + + Throw.TypeError(_realm, "Value is not an Intl.Collator"); + return null!; // Never reached + } + + /// + /// https://tc39.es/ecma402/#sec-intl.collator.prototype.compare + /// + private ClrFunction GetCompare(JsValue thisObject, JsCallArguments arguments) + { + var collator = ValidateCollator(thisObject); + + // Return a bound compare function + // The spec requires this to be a bound function stored on the collator + // For simplicity, we create a new function each time + return new ClrFunction(Engine, "", (_, args) => + { + var x = TypeConverter.ToString(args.At(0)); + var y = TypeConverter.ToString(args.At(1)); + return collator.Compare(x, y); + }, 2, PropertyFlag.Configurable); + } + + /// + /// https://tc39.es/ecma402/#sec-intl.collator.prototype.resolvedoptions + /// + private JsObject ResolvedOptions(JsValue thisObject, JsCallArguments arguments) + { + var collator = ValidateCollator(thisObject); + + var result = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject); + + result.CreateDataPropertyOrThrow("locale", collator.Locale); + result.CreateDataPropertyOrThrow("usage", collator.Usage); + result.CreateDataPropertyOrThrow("sensitivity", collator.Sensitivity); + result.CreateDataPropertyOrThrow("ignorePunctuation", collator.IgnorePunctuation); + result.CreateDataPropertyOrThrow("collation", collator.Collation); + result.CreateDataPropertyOrThrow("numeric", collator.Numeric); + result.CreateDataPropertyOrThrow("caseFirst", collator.CaseFirst); + + return result; + } } diff --git a/Jint/Native/Intl/IntlInstance.cs b/Jint/Native/Intl/IntlInstance.cs index cd52206706..e4303f3a66 100644 --- a/Jint/Native/Intl/IntlInstance.cs +++ b/Jint/Native/Intl/IntlInstance.cs @@ -32,8 +32,9 @@ protected override void Initialize() { const PropertyFlag PropertyFlags = PropertyFlag.Writable | PropertyFlag.Configurable; - var properties = new PropertyDictionary(3, checkExistingKeys: false) + var properties = new PropertyDictionary(4, checkExistingKeys: false) { + ["Collator"] = new(_realm.Intrinsics.Collator, PropertyFlags), ["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), diff --git a/Jint/Native/Intl/JsCollator.cs b/Jint/Native/Intl/JsCollator.cs new file mode 100644 index 0000000000..aebfdf07e4 --- /dev/null +++ b/Jint/Native/Intl/JsCollator.cs @@ -0,0 +1,186 @@ +using System.Globalization; +using Jint.Native.Object; + +namespace Jint.Native.Intl; + +/// +/// https://tc39.es/ecma402/#sec-collator-objects +/// Represents an Intl.Collator instance with locale-aware string comparison. +/// +internal sealed class JsCollator : ObjectInstance +{ + internal JsCollator( + Engine engine, + ObjectInstance prototype, + string locale, + string usage, + string sensitivity, + bool ignorePunctuation, + string collation, + bool numeric, + string caseFirst, + CompareInfo compareInfo, + CompareOptions compareOptions) : base(engine) + { + _prototype = prototype; + Locale = locale; + Usage = usage; + Sensitivity = sensitivity; + IgnorePunctuation = ignorePunctuation; + Collation = collation; + Numeric = numeric; + CaseFirst = caseFirst; + CompareInfo = compareInfo; + CompareOptions = compareOptions; + } + + /// + /// The locale used for collation. + /// + internal string Locale { get; } + + /// + /// "sort" or "search" - the intended usage. + /// + internal string Usage { get; } + + /// + /// "base", "accent", "case", or "variant" - the sensitivity level. + /// + internal string Sensitivity { get; } + + /// + /// Whether punctuation should be ignored. + /// + internal bool IgnorePunctuation { get; } + + /// + /// The collation type (e.g., "default", "phonebook"). + /// + internal string Collation { get; } + + /// + /// Whether numeric sorting is enabled. + /// + internal bool Numeric { get; } + + /// + /// "upper", "lower", or "false" - case ordering. + /// + internal string CaseFirst { get; } + + /// + /// The .NET CompareInfo for string comparison. + /// + internal CompareInfo CompareInfo { get; } + + /// + /// The .NET CompareOptions derived from collator settings. + /// + internal CompareOptions CompareOptions { get; } + + /// + /// Compares two strings according to the collator's locale and options. + /// + internal int Compare(string x, string y) + { + // Normalize strings to NFC form so that canonically equivalent strings compare as equal + // For example: "รถ" (U+00F6) should equal "o\u0308" (o + combining umlaut) + x = x.Normalize(System.Text.NormalizationForm.FormC); + y = y.Normalize(System.Text.NormalizationForm.FormC); + + if (Numeric) + { + // Use natural sort comparison for numeric strings + return NaturalStringCompare(x, y); + } + + var result = CompareInfo.Compare(x, y, CompareOptions); + + // Some locales (like Thai) inherently ignore punctuation/symbols in comparison. + // If ignorePunctuation is false and the result is 0 but strings differ, check if the + // difference is only in punctuation/symbols (by removing them and comparing). + if (result == 0 && !IgnorePunctuation && !string.Equals(x, y, System.StringComparison.Ordinal)) + { + // Strip all punctuation/symbols and see if what remains is equal + var xStripped = StripPunctuation(x); + var yStripped = StripPunctuation(y); + if (string.Equals(xStripped, yStripped, System.StringComparison.Ordinal)) + { + // The difference is only in punctuation/symbols which should not be ignored + result = string.CompareOrdinal(x, y); + } + } + + // Normalize result to -1, 0, or 1 (ECMA-402 only guarantees sign, but tests expect these values) + return result < 0 ? -1 : result > 0 ? 1 : 0; + } + + /// + /// Natural string comparison that handles embedded numbers. + /// For example: "a2" comes before "a10" instead of "a10" before "a2". + /// + private int NaturalStringCompare(string x, string y) + { + int xi = 0, yi = 0; + while (xi < x.Length && yi < y.Length) + { + bool xIsDigit = char.IsDigit(x[xi]); + bool yIsDigit = char.IsDigit(y[yi]); + + if (xIsDigit && yIsDigit) + { + // Extract numeric portions + int xStart = xi; + while (xi < x.Length && char.IsDigit(x[xi])) xi++; + int yStart = yi; + while (yi < y.Length && char.IsDigit(y[yi])) yi++; + + // Compare numeric values +#if NET6_0_OR_GREATER + var xNum = long.Parse(x.AsSpan(xStart, xi - xStart), System.Globalization.CultureInfo.InvariantCulture); + var yNum = long.Parse(y.AsSpan(yStart, yi - yStart), System.Globalization.CultureInfo.InvariantCulture); +#else + var xNum = long.Parse(x.Substring(xStart, xi - xStart), System.Globalization.CultureInfo.InvariantCulture); + var yNum = long.Parse(y.Substring(yStart, yi - yStart), System.Globalization.CultureInfo.InvariantCulture); +#endif + + int numCompare = xNum.CompareTo(yNum); + if (numCompare != 0) return numCompare < 0 ? -1 : 1; + } + else + { + // Compare single characters using locale settings + int charCompare = CompareInfo.Compare(x, xi, 1, y, yi, 1, CompareOptions); + if (charCompare != 0) return charCompare < 0 ? -1 : 1; + xi++; + yi++; + } + } + + // Handle remaining characters + int lengthDiff = (x.Length - xi).CompareTo(y.Length - yi); + return lengthDiff < 0 ? -1 : lengthDiff > 0 ? 1 : 0; + } + + /// + /// Strips punctuation and symbol characters from a string. + /// + private static string StripPunctuation(string s) + { + if (string.IsNullOrEmpty(s)) + { + return s; + } + + var sb = new System.Text.StringBuilder(s.Length); + foreach (var c in s) + { + if (!char.IsPunctuation(c) && !char.IsSymbol(c) && !char.IsWhiteSpace(c)) + { + sb.Append(c); + } + } + return sb.ToString(); + } +} diff --git a/Jint/Native/String/StringPrototype.cs b/Jint/Native/String/StringPrototype.cs index 9a830c8b0d..33f327f81c 100644 --- a/Jint/Native/String/StringPrototype.cs +++ b/Jint/Native/String/StringPrototype.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; +using Jint.Native.Intl; using Jint.Native.Json; using Jint.Native.Object; using Jint.Native.RegExp; @@ -878,21 +879,22 @@ private JsValue MatchAll(JsValue thisObject, JsCallArguments arguments) return _engine.Invoke(rx, GlobalSymbolRegistry.MatchAll, [s]); } + /// + /// https://tc39.es/ecma262/#sec-string.prototype.localecompare + /// https://tc39.es/ecma402/#sup-string.prototype.localecompare + /// private JsValue LocaleCompare(JsValue thisObject, JsCallArguments arguments) { TypeConverter.RequireObjectCoercible(Engine, thisObject); var s = TypeConverter.ToString(thisObject); var that = TypeConverter.ToString(arguments.At(0)); + var locales = arguments.At(1); + var options = arguments.At(2); - var culture = Engine.Options.Culture; - - if (arguments.Length > 1 && arguments[1].IsString()) - { - culture = CultureInfo.GetCultureInfo(arguments.At(1).AsString()); - } - - return culture.CompareInfo.Compare(s.Normalize(NormalizationForm.FormKD), that.Normalize(NormalizationForm.FormKD)); + // Use Intl.Collator for locale-aware comparison + var collator = (JsCollator) Engine.Realm.Intrinsics.Collator.Construct([locales, options], Engine.Realm.Intrinsics.Collator); + return collator.Compare(s, that); } ///