Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 6 additions & 10 deletions Jint.Tests.Test262/Test262Harness.settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,12 @@
// PluralRules - compact notation affects plural category
//"intl402/PluralRules/prototype/select/notation.js",

// Locale - Intl.Locale-info RegionPreference region fallback is not implemented. The spec
// (WeekInfoOfLocale/CalendarsOfLocale/HourCyclesOfLocale -> RegionPreference) selects the
// lookup region in priority order: "rg" region-override keyword > region subtag > "sd"
// subdivision keyword > Add Likely Subtags region > "001". Jint uses only the literal region
// subtag (getWeekInfo) or the .NET CultureInfo (getHourCycles) and does not honor the "rg"/"sd"
// Unicode keywords, so region-override/subdivision/region-priority cases mismatch. getCalendars
// Locale - CalendarsOfLocale/HourCyclesOfLocale do not go through RegionPreference yet
// (https://github.com/sebastienros/jint/issues/4159). The spec selects the lookup region in
// priority order: "rg" region-override keyword > region subtag > "sd" subdivision keyword >
// Add Likely Subtags region > "001". getWeekInfo does (Jint/Native/Intl/RegionPreference.cs);
// getHourCycles still reads the .NET CultureInfo and does not honor the "rg"/"sd" Unicode
// keywords, so region-override/subdivision/region-priority cases mismatch. getCalendars
// additionally has no region-specific calendar data (it always reports "gregory"), so its
// region tests are inconclusive by the test's own precondition.
"intl402/Locale/prototype/getCalendars/likely-subtags-region.js",
Expand All @@ -254,10 +254,6 @@
"intl402/Locale/prototype/getHourCycles/region-override.js",
"intl402/Locale/prototype/getHourCycles/region-priority.js",
"intl402/Locale/prototype/getHourCycles/subdivision-region.js",
"intl402/Locale/prototype/getWeekInfo/likely-subtags-region.js",
"intl402/Locale/prototype/getWeekInfo/region-override.js",
"intl402/Locale/prototype/getWeekInfo/region-priority.js",
"intl402/Locale/prototype/getWeekInfo/subdivision-region.js",

// === INTL402 TEMPORAL EXCLUSIONS ===

Expand Down
122 changes: 122 additions & 0 deletions Jint.Tests/Runtime/IntlLocaleRegionPreferenceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#nullable enable

namespace Jint.Tests.Runtime;

/// <summary>
/// <c>Intl.Locale.prototype.getWeekInfo</c> reads its week data for the region
/// https://tc39.es/ecma402/#sec-regionpreference picks: a <c>-u-rg-</c> override CLDR has week data for,
/// then the region subtag, then the region of a <c>-u-sd-</c> subdivision, then the region Add Likely
/// Subtags supplies, then <c>001</c>.
/// </summary>
/// <remarks>
/// Until issue #4159 only the region subtag was read, so a tag without one fell straight to <c>001</c>
/// (<c>en</c> was Monday, where <c>en-US</c> is Sunday) and neither keyword was looked at. test262's
/// <c>intl402/Locale/prototype/getWeekInfo/*-region*.js</c> files pin the priority order with real
/// regions; the malformed and unknown keyword values are pinned here. On this branch <c>getWeekInfo</c>
/// reads the embedded CLDR week data directly and never asks the configured <c>ICldrProvider</c>, so the
/// provider tests <c>main</c> has for this change have nothing to exercise here.
/// </remarks>
public class IntlLocaleRegionPreferenceTests
{
private readonly Engine _engine = new();

private int FirstDay(string tag) => (int) _engine.Evaluate($"new Intl.Locale('{tag}').getWeekInfo().firstDay").AsNumber();

private string Weekend(string tag) => _engine.Evaluate($"JSON.stringify(new Intl.Locale('{tag}').getWeekInfo().weekend)").AsString();

/// <summary>The table issue #4159 was filed with.</summary>
[Theory]
[InlineData("en-US-u-rg-gbzzzz", 1)]
[InlineData("en-GB-u-rg-uszzzz", 7)]
[InlineData("en", 7)]
[InlineData("en-US", 7)]
public void TheIssueTable(string tag, int expected)
{
FirstDay(tag).Should().Be(expected);
}

/// <summary>
/// Each level of the priority order, on a tag that also carries every lower level: <c>fa</c> is likely
/// <c>IR</c> (Saturday, a Friday weekend), <c>inka</c> is in <c>IN</c> (Sunday, a Sunday weekend),
/// <c>JP</c> is Sunday with a Saturday-Sunday weekend, and <c>AF</c> is Saturday with a
/// Thursday-Friday weekend. <c>eo</c> has no likely region, so it is the world's Monday.
/// </summary>
[Theory]
[InlineData("fa-JP-u-sd-inka-rg-afzzzz", 6, "[4,5]")]
[InlineData("fa-JP-u-sd-inka", 7, "[6,7]")]
[InlineData("fa-u-sd-inka", 7, "[7]")]
[InlineData("fa", 6, "[5]")]
[InlineData("eo", 1, "[6,7]")]
public void EachSignalOutranksTheOnesBelowIt(string tag, int firstDay, string weekend)
{
FirstDay(tag).Should().Be(firstDay);
Weekend(tag).Should().Be(weekend);
}

/// <summary>
/// A subdivision is only consulted when the tag has no region subtag of its own.
/// </summary>
[Theory]
[InlineData("en-u-sd-afgh", 6)]
[InlineData("en-US-u-sd-afgh", 7)]
[InlineData("en-GB-u-sd-usca", 1)]
public void TheSubdivisionStandsInForAMissingRegionSubtag(string tag, int expected)
{
FirstDay(tag).Should().Be(expected);
}

/// <summary>
/// https://tc39.es/ecma402/#sec-canonicalunicodesubdivision answers undefined for a value that is not a
/// <c>unicode_subdivision_id</c> - a region of two letters or three digits followed by one to four
/// alphanumerics - so each of these tags reads as if the keyword were absent: <c>US</c>'s Sunday, which
/// for the bare <c>en</c> is reached through its likely region.
/// </summary>
[Theory]
[InlineData("en-US-u-rg")]
[InlineData("en-US-u-rg-gbzzzzz")]
[InlineData("en-US-u-rg-123")]
[InlineData("en-US-u-rg-gb1-zzz")]
[InlineData("en-US-u-rg-g1zzz")]
[InlineData("en-u-sd")]
[InlineData("en-u-sd-gbengxx")]
[InlineData("en-u-sd-12abc")]
public void AMalformedKeywordValueIsIgnored(string tag)
{
FirstDay(tag).Should().Be(7);
}

/// <summary>
/// The region of a well-formed value is canonicalized like the region subtag of <c>"und-" + region</c>,
/// so a deprecated code and a numeric one both reach the region they alias.
/// </summary>
[Theory]
[InlineData("en-US-u-rg-ukzzzz", 1)]
[InlineData("en-US-u-rg-826zzzz", 1)]
[InlineData("en-u-sd-ukeng", 1)]
public void TheKeywordsRegionIsCanonicalized(string tag, int expected)
{
FirstDay(tag).Should().Be(expected);
}

/// <summary>
/// A well-formed value naming a region CLDR has no week data for behaves differently for the two keys.
/// The override is used only "if week data for region regionOverride are available", so <c>rg</c>
/// falls back to the region subtag; the subdivision is the region, so <c>sd</c> skips the likely
/// subtags and the region with no data reads the world's Monday.
/// </summary>
[Theory]
[InlineData("en-US-u-rg-zzzzzz", 7)]
[InlineData("en-u-sd-zzzzzz", 1)]
public void AKeywordNamingARegionWithoutWeekData(string tag, int expected)
{
FirstDay(tag).Should().Be(expected);
}

/// <summary>The first-day keyword still wins over whichever region was picked.</summary>
[Fact]
public void TheFirstDayKeywordStillWinsOverTheRegionOverride()
{
FirstDay("en-u-fw-sun-rg-gbzzzz").Should().Be(7);
Weekend("en-u-fw-sun-rg-afzzzz").Should().Be("[4,5]");
}
}
30 changes: 30 additions & 0 deletions Jint/Native/Intl/Data/WeekData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,36 @@ internal static partial class WeekData
/// </summary>
public static Dictionary<string, string> WeekendEnd => _weekendEnd;

/// <summary>
/// The region https://tc39.es/ecma402/#sec-weekinfooflocale reads its week data for: the
/// <c>-u-rg-</c> override when CLDR has week data for it, then the preferred region when CLDR has, then
/// the world.
/// </summary>
/// <remarks>
/// "Available" is read as "named in one of CLDR's <c>weekData</c> tables". The tables list only the
/// regions that differ from <c>001</c>, so for the preferred region the answer changes nothing - a region
/// with no entry reads <c>001</c> either way. It matters for the override: <c>en-US-u-rg-zzzzzz</c> names
/// a region CLDR has no week data for, and so keeps <c>US</c>'s Sunday rather than taking the world's
/// Monday.
/// </remarks>
internal static string GetLookupRegion(in RegionPreference preference)
{
if (preference.RegionOverride is { } regionOverride && HasWeekData(regionOverride))
{
return regionOverride;
}

return HasWeekData(preference.Region) ? preference.Region : RegionPreference.World;
}

private static bool HasWeekData(string region)
{
return _firstDay.ContainsKey(region)
|| _minDays.ContainsKey(region)
|| _weekendStart.ContainsKey(region)
|| _weekendEnd.ContainsKey(region);
}

/// <summary>
/// Gets the first day of week for a region (1=Monday, 7=Sunday).
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion Jint/Native/Intl/IntlUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1070,7 +1070,7 @@ private static string AsciiLowercase(string value)
/// https://tc39.es/ecma402/#sec-canonicalizeunicodelocaleid
/// Canonicalizes a Unicode locale identifier.
/// </summary>
private static string CanonicalizeUnicodeLocaleId(string locale)
internal static string CanonicalizeUnicodeLocaleId(string locale)
{
// 1. Check grandfathered tags first (highest priority)
// Use LocaleData first, then fallback to hardcoded dictionary
Expand Down
8 changes: 5 additions & 3 deletions Jint/Native/Intl/LocalePrototype.cs
Original file line number Diff line number Diff line change
Expand Up @@ -328,12 +328,14 @@ private JsObject GetTextInfo(JsValue thisObject)
private JsObject GetWeekInfo(JsValue thisObject)
{
var locale = ValidateLocale(thisObject);
var region = locale.Region;

// The embedded CLDR week data is read for the region https://tc39.es/ecma402/#sec-weekinfooflocale
// picks rather than for the region subtag alone.
var region = WeekData.GetLookupRegion(RegionPreference.Of(locale.Locale));

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
// First day of week (1=Monday, 7=Sunday). The fw extension wins over the data.
int firstDayNum;
if (locale.FirstDayOfWeek is { } firstDayOfWeek && WeekdayUValueToNumber(firstDayOfWeek) is { } overrideDay)
{
Expand Down
170 changes: 170 additions & 0 deletions Jint/Native/Intl/RegionPreference.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System.Runtime.InteropServices;
using Jint.Native.Intl.Data;

namespace Jint.Native.Intl;

/// <summary>
/// https://tc39.es/ecma402/#sec-regionpreference - the region whose data a locale-info operation reads, and
/// the <c>-u-rg-</c> region override that is tried ahead of it.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Region"/> is the first of the region subtag, the region of a <c>-u-sd-</c> subdivision, the
/// region Add Likely Subtags supplies, and <c>001</c>; it is never null. <see cref="RegionOverride"/> is the
/// region of a <c>-u-rg-</c> keyword, and whether it wins is the caller's decision: each locale-info operation
/// uses it only when its own data covers that region, so it cannot be folded into <see cref="Region"/> here.
/// </para>
/// <para>
/// Both hold a region subtag as <c>GetLocaleRegion</c> reads it off a canonicalized tag - two uppercase
/// letters or three digits.
/// </para>
/// </remarks>
[StructLayout(LayoutKind.Auto)]
internal readonly record struct RegionPreference(string Region, string? RegionOverride)
{
/// <summary>CLDR's world region, the answer when nothing in the tag or the likely subtags names one.</summary>
internal const string World = "001";

/// <summary>
/// https://tc39.es/ecma402/#sec-regionpreference for a Unicode canonicalized locale identifier.
/// </summary>
internal static RegionPreference Of(string locale)
{
// 1. Let region be GetLocaleRegion(locale).
var region = GetLocaleRegion(locale);

// 2. If region is undefined, then
if (region is null)
{
// a. Set region to CanonicalUnicodeSubdivision(locale, "sd").
region = CanonicalUnicodeSubdivision(locale, "sd");

// b. If region is undefined, then
if (region is null)
{
// i. Let maximal be the result of the Add Likely Subtags algorithm applied to locale. If an
// error is signaled, set maximal to locale.
// ii. Set maximal to CanonicalizeUnicodeLocaleId(maximal).
// Skipped: every region the likely-subtags data supplies is already canonical, and the
// step cannot move a region the tag itself did not have.
// iii. Set region to GetLocaleRegion(maximal).
// iv. If region is undefined, set region to "001".
region = GetLocaleRegion(LikelySubtags.AddLikelySubtags(locale)) ?? World;
}
}

// 3. Let regionOverride be CanonicalUnicodeSubdivision(locale, "rg").
// 4. Return { [[Region]]: region, [[RegionOverride]]: regionOverride }.
return new RegionPreference(region, CanonicalUnicodeSubdivision(locale, "rg"));
}

/// <summary>
/// https://tc39.es/ecma402/#sec-canonicalunicodesubdivision - the region a <c>-u-rg-</c> or <c>-u-sd-</c>
/// value names, or null when the key is absent, empty, or not a <c>unicode_subdivision_id</c>.
/// </summary>
/// <remarks>
/// The region is not checked against any list: <c>zzzzzz</c> names region <c>ZZ</c>, and it is the
/// caller's data that then has nothing for it.
/// </remarks>
internal static string? CanonicalUnicodeSubdivision(string locale, string key)
{
// 1. Let subdivision be UnicodeExtensionValue(locale, key).
var subdivision = UnicodeExtension.GetKeywordValue(locale, key);

// 2. If subdivision is empty, return undefined.
// 3. If subdivision cannot be matched by the unicode_subdivision_id Unicode locale nonterminal,
// return undefined.
if (string.IsNullOrEmpty(subdivision) || !TryGetSubdivisionRegionLength(subdivision!, out var regionLength))
{
return null;
}

// 4. Let region be the longest prefix of subdivision matched by the unicode_region_subtag Unicode
// locale nonterminal.
// 5. Let regionLocale be the string-concatenation of "und-" and region.
// 6. Set regionLocale to CanonicalizeUnicodeLocaleId(regionLocale).
// 7. Return GetLocaleRegion(regionLocale).
var regionLocale = IntlUtilities.CanonicalizeUnicodeLocaleId(string.Concat("und-".AsSpan(), subdivision!.AsSpan(0, regionLength)));
return GetLocaleRegion(regionLocale);
}

/// <summary>
/// https://tc39.es/ecma402/#sec-getlocaleregion - the region subtag of the tag's base name, or null.
/// </summary>
/// <remarks>
/// The base name is every subtag ahead of the first singleton. After its leading language subtag, a
/// script is four letters and a variant is four characters starting with a digit or five to eight, so the
/// one subtag of two letters or three digits there is the region.
/// </remarks>
internal static string? GetLocaleRegion(string locale)
{
var start = locale.IndexOf('-');
while ((uint) start < (uint) locale.Length)
{
start++;
var end = locale.IndexOf('-', start);
if (end < 0)
{
end = locale.Length;
}

var length = end - start;
if (length == 1)
{
// an extension singleton: the base name ends here
return null;
}

if ((length == 2 && char.IsAsciiLetter(locale[start]) && char.IsAsciiLetter(locale[start + 1]))
|| (length == 3 && char.IsAsciiDigit(locale[start]) && char.IsAsciiDigit(locale[start + 1]) && char.IsAsciiDigit(locale[start + 2])))
{
return locale.Substring(start, length).ToUpperInvariant();
}

start = end;
}

return null;
}

/// <summary>
/// Whether <paramref name="value"/> is a <c>unicode_subdivision_id</c> - a
/// <c>unicode_region_subtag</c> followed by one to four alphanumerics - and how long its region prefix is.
/// </summary>
/// <remarks>
/// https://unicode.org/reports/tr35/#unicode_subdivision_id. The region is two letters or three digits,
/// and those two shapes differ in their first character, so the longest region prefix is decided there.
/// </remarks>
private static bool TryGetSubdivisionRegionLength(string value, out int regionLength)
{
if (value.Length >= 2 && char.IsAsciiLetter(value[0]) && char.IsAsciiLetter(value[1]))
{
regionLength = 2;
}
else if (value.Length >= 3 && char.IsAsciiDigit(value[0]) && char.IsAsciiDigit(value[1]) && char.IsAsciiDigit(value[2]))
{
regionLength = 3;
}
else
{
regionLength = 0;
return false;
}

var suffixLength = value.Length - regionLength;
if (suffixLength is < 1 or > 4)
{
return false;
}

for (var i = regionLength; i < value.Length; i++)
{
if (!char.IsAsciiLetterOrDigit(value[i]))
{
return false;
}
}

return true;
}
}
Loading