From 128675770c49ff259d03ae3d810651718dff11c8 Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Wed, 4 Jun 2025 10:20:10 -0400 Subject: [PATCH] LibJS: Implement Intl.Locale.prototype.variants This is a normative change in the ECMA-402 spec. See: https://github.com/tc39/ecma402/commit/e8c995a --- Libraries/LibJS/Runtime/CommonPropertyNames.h | 1 + Libraries/LibJS/Runtime/Intl/Locale.cpp | 18 +++++++ Libraries/LibJS/Runtime/Intl/Locale.h | 2 + .../LibJS/Runtime/Intl/LocaleConstructor.cpp | 48 ++++++++++++++++--- .../LibJS/Runtime/Intl/LocalePrototype.cpp | 16 ++++++- .../LibJS/Runtime/Intl/LocalePrototype.h | 1 + .../Intl/Locale/Locale.prototype.variants.js | 44 +++++++++++++++++ 7 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 Libraries/LibJS/Tests/builtins/Intl/Locale/Locale.prototype.variants.js diff --git a/Libraries/LibJS/Runtime/CommonPropertyNames.h b/Libraries/LibJS/Runtime/CommonPropertyNames.h index ad23012ba15..348f10e377f 100644 --- a/Libraries/LibJS/Runtime/CommonPropertyNames.h +++ b/Libraries/LibJS/Runtime/CommonPropertyNames.h @@ -583,6 +583,7 @@ namespace JS { P(value) \ P(valueOf) \ P(values) \ + P(variants) \ P(wait) \ P(waitAsync) \ P(warn) \ diff --git a/Libraries/LibJS/Runtime/Intl/Locale.cpp b/Libraries/LibJS/Runtime/Intl/Locale.cpp index 7411cc525aa..876983efd14 100644 --- a/Libraries/LibJS/Runtime/Intl/Locale.cpp +++ b/Libraries/LibJS/Runtime/Intl/Locale.cpp @@ -43,6 +43,24 @@ Unicode::LocaleID const& Locale::locale_id() const return *m_cached_locale_id; } +// 15.5.5 GetLocaleVariants ( locale ), https://tc39.es/ecma402/#sec-getlocalevariants +Optional get_locale_variants(Unicode::LocaleID const& locale) +{ + // 1. Let baseName be GetLocaleBaseName(locale). + auto const& base_name = locale.language_id; + + // 2. NOTE: Each subtag in baseName that is preceded by "-" is either a unicode_script_subtag, unicode_region_subtag, + // or unicode_variant_subtag, but any substring matched by unicode_variant_subtag is strictly longer than any + // prefix thereof which could also be matched by one of the other productions. + + // 3. Let variants be the longest suffix of baseName that starts with a "-" followed by a substring that is matched + // by the unicode_variant_subtag Unicode locale nonterminal. If there is no such suffix, return undefined. + // 4. Return the substring of variants from 1. + if (base_name.variants.is_empty()) + return {}; + return MUST(String::join("-"sv, base_name.variants)); +} + // 1.1.1 CreateArrayFromListOrRestricted ( list , restricted ) static GC::Ref create_array_from_list_or_restricted(VM& vm, Vector list, Optional restricted) { diff --git a/Libraries/LibJS/Runtime/Intl/Locale.h b/Libraries/LibJS/Runtime/Intl/Locale.h index 024a6738f13..9404905623e 100644 --- a/Libraries/LibJS/Runtime/Intl/Locale.h +++ b/Libraries/LibJS/Runtime/Intl/Locale.h @@ -91,6 +91,8 @@ struct WeekInfo { Vector weekend; // [[Weekend]] }; +Optional get_locale_variants(Unicode::LocaleID const&); + GC::Ref calendars_of_locale(VM&, Locale const&); GC::Ref collations_of_locale(VM&, Locale const& locale); GC::Ref hour_cycles_of_locale(VM&, Locale const& locale); diff --git a/Libraries/LibJS/Runtime/Intl/LocaleConstructor.cpp b/Libraries/LibJS/Runtime/Intl/LocaleConstructor.cpp index 31a84770322..df4bc68491d 100644 --- a/Libraries/LibJS/Runtime/Intl/LocaleConstructor.cpp +++ b/Libraries/LibJS/Runtime/Intl/LocaleConstructor.cpp @@ -66,25 +66,61 @@ static ThrowCompletionOr update_language_id(VM& vm, StringView tag, Obje // a. If region cannot be matched by the unicode_region_subtag Unicode locale nonterminal, throw a RangeError exception. auto region = TRY(get_string_option(vm, options, vm.names.region, Unicode::is_unicode_region_subtag, {}, base_name.region)); - // 8. Let allExtensions be the suffix of tag following baseName. + // 8. Let variants be ? GetOption(options, "variants", STRING, EMPTY, GetLocaleVariants(baseName)). + auto variants = TRY(get_string_option(vm, options, vm.names.variants, nullptr, {}, get_locale_variants(*locale_id))); + Vector variant_subtags; + + // 9. If variants is not undefined, then + if (variants.has_value()) { + // a. If variants is the empty String, throw a RangeError exception. + if (variants->is_empty()) + return vm.throw_completion(ErrorType::OptionIsNotValidValue, *variants, vm.names.variants); + + // b. Let lowerVariants be the ASCII-lowercase of variants. + auto lower_variants = variants->to_ascii_lowercase(); + + // c. Let variantSubtags be StringSplitToList(lowerVariants, "-"). + variant_subtags = MUST(lower_variants.split('-', SplitBehavior::KeepEmpty)); + + HashTable seen_variants; + bool has_duplicate_variant = false; + + // d. For each element variant of variantSubtags, do + for (auto const& variant : variant_subtags) { + has_duplicate_variant |= seen_variants.set(variant) != HashSetResult::InsertedNewEntry; + + // i. If variant cannot be matched by the unicode_variant_subtag Unicode locale nonterminal, throw a RangeError exception. + if (!Unicode::is_unicode_variant_subtag(variant)) + return vm.throw_completion(ErrorType::OptionIsNotValidValue, *variants, vm.names.variants); + } + + // e. If variantSubtags contains any duplicate elements, throw a RangeError exception. + if (has_duplicate_variant) + return vm.throw_completion(ErrorType::OptionIsNotValidValue, *variants, vm.names.variants); + } + + // 10. Let allExtensions be the suffix of tag following baseName. auto& extensions = locale_id->extensions; auto& private_use_extensions = locale_id->private_use_extensions; - // 9. Let newTag be language. + // 11. Let newTag be language. Unicode::LocaleID new_tag; new_tag.language_id.language = move(language); - // 10. If script is not undefined, set newTag to the string-concatenation of newTag, "-", and script. + // 12. If script is not undefined, set newTag to the string-concatenation of newTag, "-", and script. new_tag.language_id.script = move(script); - // 11. If region is not undefined, set newTag to the string-concatenation of newTag, "-", and region. + // 13. If region is not undefined, set newTag to the string-concatenation of newTag, "-", and region. new_tag.language_id.region = move(region); - // 12. Set newTag to the string-concatenation of newTag and allExtensions. + // 14. If variants is not undefined, set newTag to the string-concatenation of newTag, "-", and variants. + new_tag.language_id.variants = move(variant_subtags); + + // 15. Set newTag to the string-concatenation of newTag and allExtensions. new_tag.extensions = move(extensions); new_tag.private_use_extensions = move(private_use_extensions); - // 13. Return newTag. + // 16. Return newTag. return new_tag.to_string(); } diff --git a/Libraries/LibJS/Runtime/Intl/LocalePrototype.cpp b/Libraries/LibJS/Runtime/Intl/LocalePrototype.cpp index ae39c11ff55..a1f7b94cbe7 100644 --- a/Libraries/LibJS/Runtime/Intl/LocalePrototype.cpp +++ b/Libraries/LibJS/Runtime/Intl/LocalePrototype.cpp @@ -39,7 +39,7 @@ void LocalePrototype::initialize(Realm& realm) define_native_function(realm, vm.names.getTextInfo, get_text_info, 0, attr); define_native_function(realm, vm.names.getWeekInfo, get_week_info, 0, attr); - // 15.3.15 Intl.Locale.prototype [ %Symbol.toStringTag% ], https://tc39.es/ecma402/#sec-intl.locale.prototype-%symbol.tostringtag% + // 15.3.16 Intl.Locale.prototype [ %Symbol.toStringTag% ], https://tc39.es/ecma402/#sec-intl.locale.prototype-%symbol.tostringtag% define_direct_property(vm.well_known_symbol_to_string_tag(), PrimitiveString::create(vm, "Intl.Locale"_string), Attribute::Configurable); define_native_accessor(realm, vm.names.baseName, base_name, {}, Attribute::Configurable); @@ -53,6 +53,7 @@ void LocalePrototype::initialize(Realm& realm) define_native_accessor(realm, vm.names.numeric, numeric, {}, Attribute::Configurable); define_native_accessor(realm, vm.names.region, region, {}, Attribute::Configurable); define_native_accessor(realm, vm.names.script, script, {}, Attribute::Configurable); + define_native_accessor(realm, vm.names.variants, variants, {}, Attribute::Configurable); } // 15.3.2 get Intl.Locale.prototype.baseName, https://tc39.es/ecma402/#sec-Intl.Locale.prototype.baseName @@ -182,6 +183,19 @@ JS_DEFINE_NATIVE_FUNCTION(LocalePrototype::to_string) return PrimitiveString::create(vm, locale_object->locale()); } +// 15.3.15 get Intl.Locale.prototype.variants, https://tc39.es/ecma402/#sec-Intl.Locale.prototype.variants +JS_DEFINE_NATIVE_FUNCTION(LocalePrototype::variants) +{ + // 1. Let loc be the this value. + // 2. Perform ? RequireInternalSlot(loc, [[InitializedLocale]]). + auto locale_object = TRY(typed_this_object(vm)); + + // 3. Return GetLocaleVariants(loc.[[Locale]]). + if (auto variants = get_locale_variants(locale_object->locale_id()); variants.has_value()) + return PrimitiveString::create(vm, variants.release_value()); + return js_undefined(); +} + #define JS_ENUMERATE_LOCALE_INFO_PROPERTIES \ __JS_ENUMERATE(calendars) \ __JS_ENUMERATE(collations) \ diff --git a/Libraries/LibJS/Runtime/Intl/LocalePrototype.h b/Libraries/LibJS/Runtime/Intl/LocalePrototype.h index 4b21c663dc8..a0788c5d38b 100644 --- a/Libraries/LibJS/Runtime/Intl/LocalePrototype.h +++ b/Libraries/LibJS/Runtime/Intl/LocalePrototype.h @@ -37,6 +37,7 @@ private: JS_DECLARE_NATIVE_FUNCTION(numeric); JS_DECLARE_NATIVE_FUNCTION(region); JS_DECLARE_NATIVE_FUNCTION(script); + JS_DECLARE_NATIVE_FUNCTION(variants); JS_DECLARE_NATIVE_FUNCTION(get_calendars); JS_DECLARE_NATIVE_FUNCTION(get_collations); JS_DECLARE_NATIVE_FUNCTION(get_hour_cycles); diff --git a/Libraries/LibJS/Tests/builtins/Intl/Locale/Locale.prototype.variants.js b/Libraries/LibJS/Tests/builtins/Intl/Locale/Locale.prototype.variants.js new file mode 100644 index 00000000000..e0b60e5a7bc --- /dev/null +++ b/Libraries/LibJS/Tests/builtins/Intl/Locale/Locale.prototype.variants.js @@ -0,0 +1,44 @@ +describe("errors", () => { + test("called on non-Locale object", () => { + expect(() => { + Intl.Locale.prototype.variants; + }).toThrowWithMessage(TypeError, "Not an object of type Intl.Locale"); + }); + + test("duplicate variants", () => { + expect(() => { + new Intl.Locale("en-abcde-abcde"); + }).toThrowWithMessage( + RangeError, + "en-abcde-abcde is not a structurally valid language tag" + ); + + expect(() => { + new Intl.Locale("en", { variants: "abcde-abcde" }); + }).toThrowWithMessage(RangeError, "abcde-abcde is not a valid value for option variants"); + }); + + test("invalid variant", () => { + expect(() => { + new Intl.Locale("en-a"); + }).toThrowWithMessage(RangeError, "en-a is not a structurally valid language tag"); + + expect(() => { + new Intl.Locale("en", { variants: "a" }); + }).toThrowWithMessage(RangeError, "a is not a valid value for option variants"); + + expect(() => { + new Intl.Locale("en", { variants: "-" }); + }).toThrowWithMessage(RangeError, "- is not a valid value for option variants"); + }); +}); + +describe("normal behavior", () => { + test("basic functionality", () => { + expect(new Intl.Locale("en").variants).toBeUndefined(); + expect(new Intl.Locale("en-abcde").variants).toBe("abcde"); + expect(new Intl.Locale("en-1234-abcde").variants).toBe("1234-abcde"); + expect(new Intl.Locale("en", { variants: "abcde" }).variants).toBe("abcde"); + expect(new Intl.Locale("en", { variants: "1234-abcde" }).variants).toBe("1234-abcde"); + }); +});