diff --git a/Directory.Packages.props b/Directory.Packages.props
index 753d3d098e..98f1872359 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,6 +11,7 @@
+
diff --git a/Jint.Tests.Test262/IcuCldrProvider.cs b/Jint.Tests.Test262/IcuCldrProvider.cs
new file mode 100644
index 0000000000..8c5c76c013
--- /dev/null
+++ b/Jint.Tests.Test262/IcuCldrProvider.cs
@@ -0,0 +1,404 @@
+#nullable enable
+
+using ICU4N.Globalization;
+using ICU4N.Impl;
+using ICU4N.Text;
+using ICU4N.Util;
+using Jint.Native.Intl;
+
+namespace Jint.Tests.Test262;
+
+///
+/// CLDR provider implementation that combines ICU4N features with default provider fallback.
+/// Uses ICU4N's PluralRules for plural category selection and ICUResourceBundle for
+/// direct CLDR data access (unit patterns, list patterns, etc.).
+/// Falls back to DefaultCldrProvider when ICU4N data is not available.
+///
+public sealed class IcuCldrProvider : ICldrProvider
+{
+ private readonly ICldrProvider _fallback = DefaultCldrProvider.Instance;
+
+ ///
+ /// Singleton instance.
+ ///
+ public static readonly IcuCldrProvider Instance = new();
+
+ private IcuCldrProvider()
+ {
+ }
+
+ // === CLDR Resource Bundle Access ===
+
+ private static UResourceBundle? GetBundle(string locale)
+ {
+ try
+ {
+ var culture = new UCultureInfo(locale);
+ return UResourceBundle.GetBundleInstance(ICUData.IcuBaseName, culture);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static UResourceBundle? GetBundleAt(UResourceBundle bundle, string path)
+ {
+ try
+ {
+ foreach (var segment in path.Split('/'))
+ {
+ bundle = bundle.Get(segment);
+ }
+ return bundle;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static string? TryGetString(UResourceBundle bundle, string key)
+ {
+ try
+ {
+ return bundle.GetString(key);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ // === List Patterns ===
+ // ICU4N doesn't have ListFormatter API, but we can access CLDR data directly
+
+ public ListPatterns? GetListPatterns(string locale, string type, string style)
+ {
+ // Map Intl type/style to CLDR path
+ var cldrType = (type, style) switch
+ {
+ ("conjunction", "long") => "standard",
+ ("conjunction", "short") => "standard-short",
+ ("conjunction", "narrow") => "standard-narrow",
+ ("disjunction", "long") => "or",
+ ("disjunction", "short") => "or-short",
+ ("disjunction", "narrow") => "or-narrow",
+ ("unit", "long") => "unit",
+ ("unit", "short") => "unit-short",
+ ("unit", "narrow") => "unit-narrow",
+ _ => "standard"
+ };
+
+ var path = $"listPattern/{cldrType}";
+
+ try
+ {
+ var bundle = GetBundle(locale);
+ if (bundle == null)
+ {
+ return _fallback.GetListPatterns(locale, type, style);
+ }
+
+ var listBundle = GetBundleAt(bundle, path);
+ if (listBundle == null)
+ {
+ return _fallback.GetListPatterns(locale, type, style);
+ }
+
+ var two = TryGetString(listBundle, "2");
+ var start = TryGetString(listBundle, "start");
+ var middle = TryGetString(listBundle, "middle");
+ var end = TryGetString(listBundle, "end");
+
+ // If we don't have the patterns, fall back
+ if (two == null && start == null && end == null)
+ {
+ return _fallback.GetListPatterns(locale, type, style);
+ }
+
+ return new ListPatterns
+ {
+ Two = two ?? "{0}, {1}",
+ Start = start ?? "{0}, {1}",
+ Middle = middle ?? "{0}, {1}",
+ End = end ?? "{0}, {1}"
+ };
+ }
+ catch
+ {
+ return _fallback.GetListPatterns(locale, type, style);
+ }
+ }
+
+ // === Relative Time Patterns ===
+ // ICU4N doesn't have RelativeDateTimeFormatter, use fallback
+ public RelativeTimePatterns? GetRelativeTimePatterns(string locale, string unit, string style)
+ => _fallback.GetRelativeTimePatterns(locale, unit, style);
+
+ public string? GetRelativeTimeSpecialPhrase(string locale, string unit, int value, bool past, string style)
+ => _fallback.GetRelativeTimeSpecialPhrase(locale, unit, value, past, style);
+
+ // === Number Formatting ===
+
+ public string? GetNumberingSystemDigits(string numberingSystem)
+ {
+ // Use ICU's numbering system data
+ try
+ {
+ var ns = NumberingSystem.GetInstanceByName(numberingSystem);
+ if (ns != null && !ns.IsAlgorithmic)
+ {
+ return ns.Description; // Contains the digit characters
+ }
+ return _fallback.GetNumberingSystemDigits(numberingSystem);
+ }
+ catch
+ {
+ return _fallback.GetNumberingSystemDigits(numberingSystem);
+ }
+ }
+
+ public CompactPatterns? GetCompactPatterns(string locale, string style)
+ => _fallback.GetCompactPatterns(locale, style);
+
+ public Jint.Native.Intl.CurrencyData? GetCurrencyData(string locale, string currencyCode)
+ => _fallback.GetCurrencyData(locale, currencyCode);
+
+ public UnitPatterns? GetUnitPatterns(string locale, string unit, string style)
+ {
+ // Map Intl unit names to CLDR paths
+ var cldrUnit = MapToCldrUnit(unit);
+ var path = $"units/{style}/{cldrUnit}";
+
+ try
+ {
+ var bundle = GetBundle(locale);
+ if (bundle == null)
+ {
+ return _fallback.GetUnitPatterns(locale, unit, style);
+ }
+
+ var unitBundle = GetBundleAt(bundle, path);
+ if (unitBundle == null)
+ {
+ return _fallback.GetUnitPatterns(locale, unit, style);
+ }
+
+ var displayName = TryGetString(unitBundle, "displayName");
+
+ // Get patterns for each plural category
+ var other = TryGetString(unitBundle, "unitPattern-count-other");
+ var one = TryGetString(unitBundle, "unitPattern-count-one");
+ var zero = TryGetString(unitBundle, "unitPattern-count-zero");
+ var two = TryGetString(unitBundle, "unitPattern-count-two");
+ var few = TryGetString(unitBundle, "unitPattern-count-few");
+ var many = TryGetString(unitBundle, "unitPattern-count-many");
+
+ // If we don't have any patterns, fall back
+ if (other == null && one == null)
+ {
+ return _fallback.GetUnitPatterns(locale, unit, style);
+ }
+
+ return new UnitPatterns
+ {
+ DisplayName = displayName ?? unit,
+ Other = other ?? $"{{0}} {unit}",
+ One = one,
+ Zero = zero,
+ Two = two,
+ Few = few,
+ Many = many
+ };
+ }
+ catch
+ {
+ return _fallback.GetUnitPatterns(locale, unit, style);
+ }
+ }
+
+ private static string MapToCldrUnit(string unit)
+ {
+ return unit switch
+ {
+ // Duration units
+ "year" or "years" => "duration-year",
+ "month" or "months" => "duration-month",
+ "week" or "weeks" => "duration-week",
+ "day" or "days" => "duration-day",
+ "hour" or "hours" => "duration-hour",
+ "minute" or "minutes" => "duration-minute",
+ "second" or "seconds" => "duration-second",
+ "millisecond" or "milliseconds" => "duration-millisecond",
+ "microsecond" or "microseconds" => "duration-microsecond",
+ "nanosecond" or "nanoseconds" => "duration-nanosecond",
+ // Length units
+ "meter" => "length-meter",
+ "kilometer" => "length-kilometer",
+ "centimeter" => "length-centimeter",
+ "millimeter" => "length-millimeter",
+ "inch" => "length-inch",
+ "foot" => "length-foot",
+ "yard" => "length-yard",
+ "mile" => "length-mile",
+ // Mass units
+ "gram" => "mass-gram",
+ "kilogram" => "mass-kilogram",
+ "milligram" => "mass-milligram",
+ "pound" => "mass-pound",
+ "ounce" => "mass-ounce",
+ // Other common units
+ "liter" => "volume-liter",
+ "milliliter" => "volume-milliliter",
+ "gallon" => "volume-gallon",
+ "celsius" => "temperature-celsius",
+ "fahrenheit" => "temperature-fahrenheit",
+ "percent" => "concentr-percent",
+ "byte" => "digital-byte",
+ "kilobyte" => "digital-kilobyte",
+ "megabyte" => "digital-megabyte",
+ "gigabyte" => "digital-gigabyte",
+ "terabyte" => "digital-terabyte",
+ _ => unit
+ };
+ }
+
+ // === Date/Time Formatting ===
+ // Note: ICU4N doesn't have DateFormatSymbols ported yet, so we use the fallback provider
+ // which uses .NET's CultureInfo for basic date/time data.
+
+ public DateTimePatterns? GetDateTimePatterns(string locale, string? dateStyle, string? timeStyle)
+ => _fallback.GetDateTimePatterns(locale, dateStyle, timeStyle);
+
+ public string[]? GetMonthNames(string locale, string style)
+ => _fallback.GetMonthNames(locale, style);
+
+ public string[]? GetWeekdayNames(string locale, string style)
+ => _fallback.GetWeekdayNames(locale, style);
+
+ public string[]? GetDayPeriods(string locale, string style)
+ => _fallback.GetDayPeriods(locale, style);
+
+ public string[]? GetEraNames(string locale, string style)
+ => _fallback.GetEraNames(locale, style);
+
+ // === Display Names ===
+
+ public string? GetLanguageDisplayName(string locale, string code)
+ {
+ try
+ {
+ var displayLocale = new UCultureInfo(locale);
+ var codeLocale = new UCultureInfo(code);
+ var result = codeLocale.GetDisplayLanguage(displayLocale);
+ return string.IsNullOrEmpty(result) ? _fallback.GetLanguageDisplayName(locale, code) : result;
+ }
+ catch
+ {
+ return _fallback.GetLanguageDisplayName(locale, code);
+ }
+ }
+
+ public string? GetRegionDisplayName(string locale, string code)
+ {
+ try
+ {
+ var displayLocale = new UCultureInfo(locale);
+ // Create a locale with just the region
+ var codeLocale = new UCultureInfo("und-" + code);
+ var result = codeLocale.GetDisplayCountry(displayLocale);
+ return string.IsNullOrEmpty(result) ? _fallback.GetRegionDisplayName(locale, code) : result;
+ }
+ catch
+ {
+ return _fallback.GetRegionDisplayName(locale, code);
+ }
+ }
+
+ public string? GetScriptDisplayName(string locale, string code)
+ {
+ try
+ {
+ var displayLocale = new UCultureInfo(locale);
+ // Create a locale with just the script
+ var codeLocale = new UCultureInfo("und-" + code);
+ var result = codeLocale.GetDisplayScript(displayLocale);
+ return string.IsNullOrEmpty(result) ? _fallback.GetScriptDisplayName(locale, code) : result;
+ }
+ catch
+ {
+ return _fallback.GetScriptDisplayName(locale, code);
+ }
+ }
+
+ public string? GetCurrencyDisplayName(string locale, string code)
+ => _fallback.GetCurrencyDisplayName(locale, code);
+
+ // === Locale Data ===
+
+ public string? GetLikelySubtags(string locale)
+ {
+ try
+ {
+ var icuLocale = new UCultureInfo(locale);
+ var maximized = UCultureInfo.AddLikelySubtags(icuLocale);
+ return maximized?.Name ?? _fallback.GetLikelySubtags(locale);
+ }
+ catch
+ {
+ return _fallback.GetLikelySubtags(locale);
+ }
+ }
+
+ public WeekInfo? GetWeekInfo(string locale)
+ => _fallback.GetWeekInfo(locale);
+
+ // === Supported Values ===
+
+ public IReadOnlyCollection GetSupportedCalendars()
+ => _fallback.GetSupportedCalendars();
+
+ public IReadOnlyCollection GetSupportedCollations()
+ => _fallback.GetSupportedCollations();
+
+ public IReadOnlyCollection GetSupportedCurrencies()
+ => _fallback.GetSupportedCurrencies();
+
+ public IReadOnlyCollection GetSupportedNumberingSystems()
+ {
+ // Use fallback - ICU4N's list may not include all required numbering systems
+ // Our embedded NumberingSystemData has the complete ECMA-402 spec list
+ return _fallback.GetSupportedNumberingSystems();
+ }
+
+ public IReadOnlyCollection GetSupportedTimeZones()
+ => _fallback.GetSupportedTimeZones();
+
+ public IReadOnlyCollection GetSupportedUnits()
+ => _fallback.GetSupportedUnits();
+
+ // === Plural Rules ===
+
+ public string SelectPluralCategory(string locale, double value, string type)
+ {
+ try
+ {
+ var culture = new UCultureInfo(locale);
+ var pluralType = string.Equals(type, "ordinal", StringComparison.Ordinal)
+ ? PluralType.Ordinal
+ : PluralType.Cardinal;
+
+ var rules = PluralRules.GetInstance(culture, pluralType);
+ var category = rules.Select(value);
+
+ // ICU4N returns the category name (e.g., "one", "other", "few", "many", "zero", "two")
+ return category ?? "other";
+ }
+ catch
+ {
+ // Fallback to default provider's English rules
+ return _fallback.SelectPluralCategory(locale, value, type);
+ }
+ }
+}
diff --git a/Jint.Tests.Test262/Jint.Tests.Test262.csproj b/Jint.Tests.Test262/Jint.Tests.Test262.csproj
index f2312c1e6a..093f51fb0b 100644
--- a/Jint.Tests.Test262/Jint.Tests.Test262.csproj
+++ b/Jint.Tests.Test262/Jint.Tests.Test262.csproj
@@ -7,6 +7,7 @@
false
$(NoWarn);CS8002
Generated
+ true
@@ -16,6 +17,7 @@
+
diff --git a/Jint.Tests.Test262/Test262Harness.settings.json b/Jint.Tests.Test262/Test262Harness.settings.json
index bf06f5bde7..4dcafe68d2 100644
--- a/Jint.Tests.Test262/Test262Harness.settings.json
+++ b/Jint.Tests.Test262/Test262Harness.settings.json
@@ -1,5 +1,5 @@
{
- "SuiteGitSha": "9079aeefcefcd55b0e994fb8bda51e06827337bd",
+ "SuiteGitSha": "f85c9511a380b9abb2877c42cc47e8fce06d56c5",
//"SuiteDirectory": "//mnt/c/work/test262",
"TargetPath": "./Generated",
"Namespace": "Jint.Tests.Test262",
@@ -18,8 +18,7 @@
"CanBlockIsFalse"
],
"ExcludedDirectories": [
- "annexB",
- "intl402"
+ "annexB"
],
"ExcludedFiles": [
@@ -153,7 +152,80 @@
// TLA with complex module graph - dynamic import inside TLA module doesn't settle
// when module has both static and dynamic imports with cyclic dependencies
- "language/module-code/top-level-await/module-graphs-does-not-hang.js"
+ "language/module-code/top-level-await/module-graphs-does-not-hang.js",
+
+ // === INTL402 EXCLUSIONS ===
+ // The following tests require full CLDR locale data support which is not available in the default provider.
+ // Many of these tests require non-English locales (Spanish, Polish, German, Chinese, etc.) or
+ // advanced features like formatRange, calendar-specific formatting, or complex locale canonicalization.
+
+ // Collator - German phonebook collation and locale-specific collation data
+ "intl402/Collator/prototype/compare/non-normative-phonebook.js",
+ "intl402/Collator/prototype/resolvedOptions/resolved-collation-unicode-extensions-and-options.js",
+ "intl402/Collator/unicode-ext-seq-in-private-tag.js",
+ "intl402/Collator/unicode-ext-value-collation.js",
+ "intl402/Collator/usage-de.js",
+
+ // DateTimeFormat - hanidec numbering system has different AM/PM behavior in ICU/CLDR
+ "intl402/DateTimeFormat/prototype/format/numbering-system.js",
+
+ // DateTimeFormat - Chinese/Dangi calendar date range issues (.NET ChineseLunisolarCalendar limitations)
+ "intl402/DateTimeFormat/prototype/formatRangeToParts/chinese-calendar-dates.js",
+ "intl402/DateTimeFormat/prototype/formatRangeToParts/dangi-calendar-dates.js",
+ "intl402/DateTimeFormat/prototype/formatToParts/chinese-calendar-dates.js",
+
+ // DisplayNames - locale-specific display names and Symbol edge cases
+ "intl402/DisplayNames/locales-symbol-length.js",
+
+ // DurationFormat - formatting with specific styles and locales
+ "intl402/DurationFormat/prototype/format/duration-out-of-range-3.js",
+ "intl402/DurationFormat/prototype/format/duration-out-of-range-4.js",
+ // Requires Spanish unit patterns (ICU4N doesn't include unit data, only core locale data)
+ "intl402/DurationFormat/prototype/format/mixed-non-numeric-styles-es.js",
+ "intl402/DurationFormat/prototype/format/precision-exact-mathematical-values.js",
+ "intl402/DurationFormat/prototype/resolvedOptions/resolved-numbering-system-unicode-extensions-and-options.js",
+
+ // Locale canonicalization - variant/region aliasing requires CLDR data
+ "intl402/Intl/getCanonicalLocales/complex-region-subtag-replacement.js",
+ "intl402/Intl/getCanonicalLocales/non-iana-canon.js",
+ "intl402/Intl/getCanonicalLocales/preferred-variant.js",
+ "intl402/Locale/reject-duplicate-variants-in-tlang.js",
+
+ // NumberFormat - locale-specific formatting and formatRange
+ "intl402/NumberFormat/prototype/format/format-significant-digits.js",
+ "intl402/NumberFormat/prototype/format/useGrouping-extended-en-IN.js",
+ "intl402/NumberFormat/prototype/format/value-decimal-string.js",
+ // formatRange requires locale-specific CLDR range patterns and part collapsing
+ "intl402/NumberFormat/prototype/formatRange/en-US.js",
+ "intl402/NumberFormat/prototype/formatRange/pt-PT.js",
+ "intl402/NumberFormat/prototype/formatRangeToParts/en-US.js",
+ // Unit formatting formatToParts not yet implemented
+ "intl402/NumberFormat/prototype/formatToParts/unit-de-DE.js",
+ "intl402/NumberFormat/prototype/formatToParts/unit-ja-JP.js",
+ "intl402/NumberFormat/prototype/formatToParts/unit-ko-KR.js",
+ "intl402/NumberFormat/prototype/formatToParts/unit-zh-TW.js",
+
+ // PluralRules - plural categories require full CLDR data, notation requires compact support
+ "intl402/PluralRules/prototype/resolvedOptions/plural-categories-order.js",
+ "intl402/PluralRules/prototype/select/notation.js",
+
+ // String - Turkish/Lithuanian/Azeri locale-specific case conversions
+ "intl402/String/prototype/toLocaleLowerCase/capital_I_with_dot.js",
+ "intl402/String/prototype/toLocaleLowerCase/special_casing_Azeri.js",
+ "intl402/String/prototype/toLocaleLowerCase/special_casing_Lithuanian.js",
+ "intl402/String/prototype/toLocaleLowerCase/special_casing_Turkish.js",
+ "intl402/String/prototype/toLocaleLowerCase/validates-all-locale-identifiers.js",
+ "intl402/String/prototype/toLocaleUpperCase/validates-all-locale-identifiers.js",
+
+ // supportedValuesOf - currencies accepted by DisplayNames check
+ "intl402/Intl/supportedValuesOf/currencies-accepted-by-DisplayNames.js",
+
+ // Misc intl402 tests requiring full locale support
+ "intl402/fallback-locales-are-supported.js",
+ "intl402/supportedLocalesOf-consistent-with-resolvedOptions.js",
+
+ // Performance: This test iterates through all locales with all constructors - very slow
+ "intl402/supportedLocalesOf-unicode-extensions-ignored.js"
]
}
\ No newline at end of file
diff --git a/Jint.Tests.Test262/Test262Test.cs b/Jint.Tests.Test262/Test262Test.cs
index dca9890f76..d19161e7a3 100644
--- a/Jint.Tests.Test262/Test262Test.cs
+++ b/Jint.Tests.Test262/Test262Test.cs
@@ -22,6 +22,8 @@ private static Engine BuildTestExecutor(Test262File file, Test262AgentManager? a
cfg.EnableModules(new Test262ModuleLoader(State.Test262Stream.Options.FileSystem, relativePath));
cfg.ExperimentalFeatures = ExperimentalFeature.All;
cfg.TimeoutInterval(TimeSpan.FromSeconds(30));
+ // Use ICU-based CLDR provider for better Intl support
+ cfg.Intl.CldrProvider = IcuCldrProvider.Instance;
});
if (file.Flags.Contains("raw"))
diff --git a/Jint.Tests/Runtime/IntlTests.cs b/Jint.Tests/Runtime/IntlTests.cs
new file mode 100644
index 0000000000..4dac9841f6
--- /dev/null
+++ b/Jint.Tests/Runtime/IntlTests.cs
@@ -0,0 +1,655 @@
+namespace Jint.Tests.Runtime;
+
+public class IntlTests
+{
+ private readonly Engine _engine;
+
+ public IntlTests()
+ {
+ _engine = new Engine();
+ }
+
+ [Fact]
+ public void IntlObjectExists()
+ {
+ var result = _engine.Evaluate("typeof Intl");
+ Assert.Equal("object", result.AsString());
+ }
+
+ [Fact]
+ public void IntlHasGetCanonicalLocales()
+ {
+ var result = _engine.Evaluate("typeof Intl.getCanonicalLocales");
+ Assert.Equal("function", result.AsString());
+ }
+
+ [Fact]
+ public void IntlHasSupportedValuesOf()
+ {
+ var result = _engine.Evaluate("typeof Intl.supportedValuesOf");
+ Assert.Equal("function", result.AsString());
+ }
+
+ [Fact]
+ public void GetCanonicalLocalesWithUndefined()
+ {
+ var result = _engine.Evaluate("Intl.getCanonicalLocales(undefined)");
+ Assert.True(result.IsArray());
+ Assert.Equal((uint) 0, result.AsArray().Length);
+ }
+
+ [Fact]
+ public void GetCanonicalLocalesWithString()
+ {
+ var result = _engine.Evaluate("Intl.getCanonicalLocales('en-US')");
+ Assert.True(result.IsArray());
+ var array = result.AsArray();
+ Assert.Equal((uint) 1, array.Length);
+ Assert.Equal("en-US", array[0].AsString());
+ }
+
+ [Fact]
+ public void GetCanonicalLocalesWithArray()
+ {
+ var result = _engine.Evaluate("Intl.getCanonicalLocales(['en-US', 'de-DE'])");
+ Assert.True(result.IsArray());
+ var array = result.AsArray();
+ Assert.Equal((uint) 2, array.Length);
+ }
+
+ [Fact]
+ public void GetCanonicalLocalesCanonicalizesCase()
+ {
+ // Should canonicalize 'en-us' to 'en-US'
+ var result = _engine.Evaluate("Intl.getCanonicalLocales('en-us')");
+ Assert.True(result.IsArray());
+ var array = result.AsArray();
+ Assert.Equal((uint) 1, array.Length);
+ // Result should be canonicalized
+ Assert.NotNull(array[0].AsString());
+ }
+
+ [Fact]
+ public void SupportedValuesOfCalendar()
+ {
+ var result = _engine.Evaluate("Intl.supportedValuesOf('calendar')");
+ Assert.True(result.IsArray());
+ var array = result.AsArray();
+ Assert.True(array.Length > 0);
+ }
+
+ [Fact]
+ public void SupportedValuesOfCurrency()
+ {
+ var result = _engine.Evaluate("Intl.supportedValuesOf('currency')");
+ Assert.True(result.IsArray());
+ var array = result.AsArray();
+ Assert.True(array.Length > 0);
+ }
+
+ [Fact]
+ public void SupportedValuesOfUnit()
+ {
+ var result = _engine.Evaluate("Intl.supportedValuesOf('unit')");
+ Assert.True(result.IsArray());
+ var array = result.AsArray();
+ Assert.True(array.Length > 0);
+ // Should contain common units
+ var units = new List();
+ for (uint i = 0; i < array.Length; i++)
+ {
+ units.Add(array[i].AsString());
+ }
+ Assert.Contains("meter", units);
+ Assert.Contains("second", units);
+ }
+
+ [Fact]
+ public void SupportedValuesOfInvalidKeyThrows()
+ {
+ Assert.Throws(() =>
+ _engine.Evaluate("Intl.supportedValuesOf('invalid')"));
+ }
+
+ [Fact]
+ public void IntlToStringTag()
+ {
+ var result = _engine.Evaluate("Object.prototype.toString.call(Intl)");
+ Assert.Equal("[object Intl]", result.AsString());
+ }
+
+ // Intl.Locale tests
+ [Fact]
+ public void LocaleConstructorExists()
+ {
+ var result = _engine.Evaluate("typeof Intl.Locale");
+ Assert.Equal("function", result.AsString());
+ }
+
+ [Fact]
+ public void LocaleCanBeConstructed()
+ {
+ var result = _engine.Evaluate("new Intl.Locale('en-US')");
+ Assert.NotNull(result);
+ Assert.True(result.IsObject());
+ }
+
+ [Fact]
+ public void LocaleRequiresNew()
+ {
+ Assert.Throws(() =>
+ _engine.Evaluate("Intl.Locale('en-US')"));
+ }
+
+ [Fact]
+ public void LocaleToStringReturnsTag()
+ {
+ var result = _engine.Evaluate("new Intl.Locale('en-US').toString()");
+ Assert.Equal("en-US", result.AsString());
+ }
+
+ [Fact]
+ public void LocaleLanguageProperty()
+ {
+ var result = _engine.Evaluate("new Intl.Locale('en-US').language");
+ Assert.Equal("en", result.AsString());
+ }
+
+ [Fact]
+ public void LocaleRegionProperty()
+ {
+ var result = _engine.Evaluate("new Intl.Locale('en-US').region");
+ Assert.Equal("US", result.AsString());
+ }
+
+ [Fact]
+ public void LocaleScriptProperty()
+ {
+ var result = _engine.Evaluate("new Intl.Locale('zh-Hans-CN').script");
+ Assert.Equal("Hans", result.AsString());
+ }
+
+ [Fact]
+ public void LocaleBaseNameProperty()
+ {
+ var result = _engine.Evaluate("new Intl.Locale('en-US').baseName");
+ Assert.Equal("en-US", result.AsString());
+ }
+
+ [Fact]
+ public void LocaleWithCalendarOption()
+ {
+ var result = _engine.Evaluate("new Intl.Locale('en-US', { calendar: 'gregory' }).calendar");
+ Assert.Equal("gregory", result.AsString());
+ }
+
+ [Fact]
+ public void LocaleWithHourCycleOption()
+ {
+ var result = _engine.Evaluate("new Intl.Locale('en-US', { hourCycle: 'h12' }).hourCycle");
+ Assert.Equal("h12", result.AsString());
+ }
+
+ [Fact]
+ public void LocaleWithNumericOption()
+ {
+ var result = _engine.Evaluate("new Intl.Locale('en-US', { numeric: true }).numeric");
+ Assert.True(result.AsBoolean());
+ }
+
+ [Fact]
+ public void LocaleToStringTagIsCorrect()
+ {
+ var result = _engine.Evaluate("Object.prototype.toString.call(new Intl.Locale('en-US'))");
+ Assert.Equal("[object Intl.Locale]", result.AsString());
+ }
+
+ [Fact]
+ public void LocaleMinimize()
+ {
+ var result = _engine.Evaluate("new Intl.Locale('en-US').minimize().toString()");
+ Assert.Equal("en", result.AsString());
+ }
+
+ [Fact]
+ public void LocaleWithUnicodeExtension()
+ {
+ var result = _engine.Evaluate("new Intl.Locale('en-US-u-ca-gregory').calendar");
+ Assert.Equal("gregory", result.AsString());
+ }
+
+ [Fact]
+ public void LocaleInvalidTagThrows()
+ {
+ Assert.Throws(() =>
+ _engine.Evaluate("new Intl.Locale('invalid tag with spaces')"));
+ }
+
+ // Intl.Collator tests
+ [Fact]
+ public void CollatorConstructorExists()
+ {
+ var result = _engine.Evaluate("typeof Intl.Collator");
+ Assert.Equal("function", result.AsString());
+ }
+
+ [Fact]
+ public void CollatorCanBeConstructed()
+ {
+ var result = _engine.Evaluate("new Intl.Collator('en-US')");
+ Assert.NotNull(result);
+ Assert.True(result.IsObject());
+ }
+
+ [Fact]
+ public void CollatorToStringTagIsCorrect()
+ {
+ var result = _engine.Evaluate("Object.prototype.toString.call(new Intl.Collator('en-US'))");
+ Assert.Equal("[object Intl.Collator]", result.AsString());
+ }
+
+ [Fact]
+ public void CollatorCompareReturnsFunction()
+ {
+ var result = _engine.Evaluate("typeof new Intl.Collator('en-US').compare");
+ Assert.Equal("function", result.AsString());
+ }
+
+ [Fact]
+ public void CollatorCompareSortsStrings()
+ {
+ var result = _engine.Evaluate(@"
+ var collator = new Intl.Collator('en-US');
+ var compare = collator.compare;
+ compare('a', 'b')
+ ");
+ Assert.True(result.AsNumber() < 0);
+ }
+
+ [Fact]
+ public void CollatorCompareWithCaseSensitivity()
+ {
+ var result = _engine.Evaluate(@"
+ var collator = new Intl.Collator('en-US', { sensitivity: 'case' });
+ collator.compare('a', 'A')
+ ");
+ // With 'case' sensitivity, 'a' and 'A' should be different
+ Assert.True(result.AsNumber() != 0);
+ }
+
+ [Fact]
+ public void CollatorCompareWithBaseSensitivity()
+ {
+ var result = _engine.Evaluate(@"
+ var collator = new Intl.Collator('en-US', { sensitivity: 'base' });
+ collator.compare('a', 'A')
+ ");
+ // With 'base' sensitivity, 'a' and 'A' should be equal
+ Assert.Equal(0, result.AsNumber());
+ }
+
+ [Fact]
+ public void CollatorResolvedOptions()
+ {
+ var result = _engine.Evaluate(@"
+ var options = new Intl.Collator('en-US', { usage: 'search' }).resolvedOptions();
+ options.usage
+ ");
+ Assert.Equal("search", result.AsString());
+ }
+
+ [Fact]
+ public void CollatorSupportedLocalesOf()
+ {
+ var result = _engine.Evaluate("Intl.Collator.supportedLocalesOf(['en-US', 'de-DE'])");
+ Assert.True(result.IsArray());
+ }
+
+ [Fact]
+ public void CollatorSortingWithCompare()
+ {
+ var result = _engine.Evaluate(@"
+ var items = ['z', 'a', 'c', 'b'];
+ items.sort(new Intl.Collator('en-US').compare);
+ items.join(',')
+ ");
+ Assert.Equal("a,b,c,z", result.AsString());
+ }
+
+ // Intl.NumberFormat tests
+ [Fact]
+ public void NumberFormatConstructorExists()
+ {
+ var result = _engine.Evaluate("typeof Intl.NumberFormat");
+ Assert.Equal("function", result.AsString());
+ }
+
+ [Fact]
+ public void NumberFormatCanBeConstructed()
+ {
+ var result = _engine.Evaluate("new Intl.NumberFormat('en-US')");
+ Assert.NotNull(result);
+ Assert.True(result.IsObject());
+ }
+
+ [Fact]
+ public void NumberFormatToStringTagIsCorrect()
+ {
+ var result = _engine.Evaluate("Object.prototype.toString.call(new Intl.NumberFormat('en-US'))");
+ Assert.Equal("[object Intl.NumberFormat]", result.AsString());
+ }
+
+ [Fact]
+ public void NumberFormatReturnsFunction()
+ {
+ var result = _engine.Evaluate("typeof new Intl.NumberFormat('en-US').format");
+ Assert.Equal("function", result.AsString());
+ }
+
+ [Fact]
+ public void NumberFormatFormatsDecimal()
+ {
+ var result = _engine.Evaluate("new Intl.NumberFormat('en-US').format(1234.567)");
+ Assert.Contains("1", result.AsString());
+ Assert.Contains("234", result.AsString());
+ }
+
+ [Fact]
+ public void NumberFormatFormatsCurrency()
+ {
+ var result = _engine.Evaluate("new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.56)");
+ // Should contain the dollar sign and the number
+ Assert.Contains("1", result.AsString());
+ }
+
+ [Fact]
+ public void NumberFormatFormatsPercent()
+ {
+ var result = _engine.Evaluate("new Intl.NumberFormat('en-US', { style: 'percent' }).format(0.5)");
+ // Should contain "50" for 50%
+ Assert.Contains("50", result.AsString());
+ }
+
+ [Fact]
+ public void NumberFormatResolvedOptions()
+ {
+ var result = _engine.Evaluate(@"
+ var options = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).resolvedOptions();
+ options.currency
+ ");
+ Assert.Equal("EUR", result.AsString());
+ }
+
+ [Fact]
+ public void NumberFormatResolvedOptionsStyle()
+ {
+ var result = _engine.Evaluate(@"
+ var options = new Intl.NumberFormat('en-US', { style: 'percent' }).resolvedOptions();
+ options.style
+ ");
+ Assert.Equal("percent", result.AsString());
+ }
+
+ [Fact]
+ public void NumberFormatSupportedLocalesOf()
+ {
+ var result = _engine.Evaluate("Intl.NumberFormat.supportedLocalesOf(['en-US', 'de-DE'])");
+ Assert.True(result.IsArray());
+ }
+
+ [Fact]
+ public void NumberFormatWithUnit()
+ {
+ var result = _engine.Evaluate("new Intl.NumberFormat('en-US', { style: 'unit', unit: 'kilometer' }).format(100)");
+ Assert.Contains("100", result.AsString());
+ Assert.Contains("km", result.AsString());
+ }
+
+ [Fact]
+ public void NumberFormatCurrencyRequiresCurrency()
+ {
+ Assert.Throws(() =>
+ _engine.Evaluate("new Intl.NumberFormat('en-US', { style: 'currency' })"));
+ }
+
+ [Fact]
+ public void NumberFormatUnitRequiresUnit()
+ {
+ Assert.Throws(() =>
+ _engine.Evaluate("new Intl.NumberFormat('en-US', { style: 'unit' })"));
+ }
+
+ // Intl.DateTimeFormat tests
+ [Fact]
+ public void DateTimeFormatConstructorExists()
+ {
+ var result = _engine.Evaluate("typeof Intl.DateTimeFormat");
+ Assert.Equal("function", result.AsString());
+ }
+
+ [Fact]
+ public void DateTimeFormatCanBeConstructed()
+ {
+ var result = _engine.Evaluate("new Intl.DateTimeFormat('en-US')");
+ Assert.NotNull(result);
+ Assert.True(result.IsObject());
+ }
+
+ [Fact]
+ public void DateTimeFormatToStringTagIsCorrect()
+ {
+ var result = _engine.Evaluate("Object.prototype.toString.call(new Intl.DateTimeFormat('en-US'))");
+ Assert.Equal("[object Intl.DateTimeFormat]", result.AsString());
+ }
+
+ [Fact]
+ public void DateTimeFormatFormatReturnsFunction()
+ {
+ var result = _engine.Evaluate("typeof new Intl.DateTimeFormat('en-US').format");
+ Assert.Equal("function", result.AsString());
+ }
+
+ [Fact]
+ public void DateTimeFormatFormatsDate()
+ {
+ var result = _engine.Evaluate("new Intl.DateTimeFormat('en-US').format(new Date(2024, 0, 15, 12, 0, 0))");
+ // Should contain the date components - use noon to avoid timezone issues
+ Assert.Contains("1", result.AsString()); // Month or day
+ Assert.Contains("2024", result.AsString()); // Year
+ // Day could be 14, 15, or 16 depending on timezone, just check it's a valid date
+ Assert.True(result.AsString().Length > 0);
+ }
+
+ [Fact]
+ public void DateTimeFormatWithDateStyle()
+ {
+ var result = _engine.Evaluate("new Intl.DateTimeFormat('en-US', { dateStyle: 'short' }).format(new Date(2024, 0, 15))");
+ Assert.NotNull(result.AsString());
+ Assert.True(result.AsString().Length > 0);
+ }
+
+ [Fact]
+ public void DateTimeFormatWithTimeStyle()
+ {
+ var result = _engine.Evaluate("new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }).format(new Date(2024, 0, 15, 14, 30, 0))");
+ Assert.NotNull(result.AsString());
+ Assert.True(result.AsString().Length > 0);
+ }
+
+ [Fact]
+ public void DateTimeFormatResolvedOptions()
+ {
+ var result = _engine.Evaluate(@"
+ var options = new Intl.DateTimeFormat('en-US', { dateStyle: 'full' }).resolvedOptions();
+ options.dateStyle
+ ");
+ Assert.Equal("full", result.AsString());
+ }
+
+ [Fact]
+ public void DateTimeFormatResolvedOptionsLocale()
+ {
+ var result = _engine.Evaluate(@"
+ var options = new Intl.DateTimeFormat('en-US').resolvedOptions();
+ options.locale
+ ");
+ // Should return a locale string
+ Assert.NotNull(result.AsString());
+ }
+
+ [Fact]
+ public void DateTimeFormatResolvedOptionsCalendar()
+ {
+ var result = _engine.Evaluate(@"
+ var options = new Intl.DateTimeFormat('en-US').resolvedOptions();
+ options.calendar
+ ");
+ // Default calendar should be 'gregory'
+ Assert.Equal("gregory", result.AsString());
+ }
+
+ [Fact]
+ public void DateTimeFormatSupportedLocalesOf()
+ {
+ var result = _engine.Evaluate("Intl.DateTimeFormat.supportedLocalesOf(['en-US', 'de-DE'])");
+ Assert.True(result.IsArray());
+ }
+
+ [Fact]
+ public void DateTimeFormatWithYearMonthDay()
+ {
+ var result = _engine.Evaluate(@"
+ new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric'
+ }).format(new Date(2024, 0, 15))
+ ");
+ Assert.Contains("2024", result.AsString());
+ }
+
+ [Fact]
+ public void DateTimeFormatWithHourMinute()
+ {
+ var result = _engine.Evaluate(@"
+ new Intl.DateTimeFormat('en-US', {
+ hour: 'numeric',
+ minute: '2-digit'
+ }).format(new Date(2024, 0, 15, 14, 30, 0))
+ ");
+ // Should contain time components
+ Assert.True(result.AsString().Contains("30") || result.AsString().Contains(":"));
+ }
+
+ [Fact]
+ public void DateTimeFormatFormatToParts()
+ {
+ var result = _engine.Evaluate(@"
+ var parts = new Intl.DateTimeFormat('en-US').formatToParts(new Date(2024, 0, 15));
+ Array.isArray(parts)
+ ");
+ Assert.True(result.AsBoolean());
+ }
+
+ [Fact]
+ public void DateTimeFormatFormatToPartsHasTypeAndValue()
+ {
+ var result = _engine.Evaluate(@"
+ var parts = new Intl.DateTimeFormat('en-US').formatToParts(new Date(2024, 0, 15));
+ parts.length > 0 && parts[0].type !== undefined && parts[0].value !== undefined
+ ");
+ Assert.True(result.AsBoolean());
+ }
+
+ [Fact]
+ public void DateTimeFormatWithWeekday()
+ {
+ var result = _engine.Evaluate(@"
+ new Intl.DateTimeFormat('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ }).format(new Date(2024, 0, 15))
+ ");
+ // Should contain a weekday name (Monday, January 15, 2024)
+ Assert.True(result.AsString().Length > 10);
+ }
+
+ [Fact]
+ public void DateTimeFormatWithHourCycle()
+ {
+ var result = _engine.Evaluate(@"
+ var options = new Intl.DateTimeFormat('en-US', {
+ hour: 'numeric',
+ hourCycle: 'h23'
+ }).resolvedOptions();
+ options.hourCycle
+ ");
+ Assert.Equal("h23", result.AsString());
+ }
+
+ [Fact]
+ public void DateTimeFormatWithTimeZone()
+ {
+ var result = _engine.Evaluate(@"
+ var options = new Intl.DateTimeFormat('en-US', {
+ timeZone: 'UTC'
+ }).resolvedOptions();
+ options.timeZone
+ ");
+ Assert.Equal("UTC", result.AsString());
+ }
+
+ [Fact]
+ public void DateTimeFormatInvalidTimeZoneThrows()
+ {
+ Assert.Throws(() =>
+ _engine.Evaluate("new Intl.DateTimeFormat('en-US', { timeZone: 'Invalid/TimeZone' })"));
+ }
+
+ [Fact]
+ public void DateTimeFormatFormatsUndefinedAsNow()
+ {
+ var result = _engine.Evaluate("new Intl.DateTimeFormat('en-US').format()");
+ // Should return a non-empty string (current date formatted)
+ Assert.NotNull(result.AsString());
+ Assert.True(result.AsString().Length > 0);
+ }
+
+ [Fact]
+ public void DateTimeFormatFormatsTimestamp()
+ {
+ var result = _engine.Evaluate("new Intl.DateTimeFormat('en-US').format(1705363200000)"); // Jan 15, 2024 UTC
+ Assert.NotNull(result.AsString());
+ Assert.True(result.AsString().Length > 0);
+ }
+
+ [Fact]
+ public void DateTimeFormatWithMonth2Digit()
+ {
+ var result = _engine.Evaluate(@"
+ new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ }).format(new Date(2024, 0, 5))
+ ");
+ // With 2-digit, should have leading zeros
+ Assert.Contains("01", result.AsString()); // Month
+ }
+
+ [Fact]
+ public void DateTimeFormatInvalidDateStyleThrows()
+ {
+ Assert.Throws(() =>
+ _engine.Evaluate("new Intl.DateTimeFormat('en-US', { dateStyle: 'invalid' })"));
+ }
+
+ [Fact]
+ public void DateTimeFormatInvalidWeekdayThrows()
+ {
+ Assert.Throws(() =>
+ _engine.Evaluate("new Intl.DateTimeFormat('en-US', { weekday: 'invalid' })"));
+ }
+}
diff --git a/Jint/Native/Intl/DateTimeFormatPrototype.cs b/Jint/Native/Intl/DateTimeFormatPrototype.cs
index 50e4c01a58..ca03e826d5 100644
--- a/Jint/Native/Intl/DateTimeFormatPrototype.cs
+++ b/Jint/Native/Intl/DateTimeFormatPrototype.cs
@@ -303,7 +303,26 @@ private JsObject ResolvedOptions(JsValue thisObject, JsCallArguments arguments)
// timeZone is always present - use local timezone if not specified
// Convert Windows timezone IDs to IANA format per ECMA-402
- var timeZoneId = dateTimeFormat.TimeZone ?? TimeZoneInfo.Local.Id;
+ string timeZoneId;
+ if (dateTimeFormat.TimeZone != null)
+ {
+ // User explicitly specified timezone - preserve as-is
+ timeZoneId = dateTimeFormat.TimeZone;
+ }
+ else
+ {
+ // Default timezone from system - canonicalize Etc/UTC variants to UTC
+ timeZoneId = TimeZoneInfo.Local.Id;
+ // On Linux, TimeZoneInfo.Local.Id often returns "Etc/UTC" which should be
+ // canonicalized to "UTC" for default timezone (but preserved if user-specified)
+ if (string.Equals(timeZoneId, "Etc/UTC", StringComparison.Ordinal) ||
+ string.Equals(timeZoneId, "Etc/UCT", StringComparison.Ordinal) ||
+ string.Equals(timeZoneId, "Etc/Universal", StringComparison.Ordinal) ||
+ string.Equals(timeZoneId, "Etc/Zulu", StringComparison.Ordinal))
+ {
+ timeZoneId = "UTC";
+ }
+ }
result.CreateDataPropertyOrThrow("timeZone", ToIanaTimeZoneId(timeZoneId));
// hourCycle and hour12 should be returned if hour is present OR if timeStyle is set
diff --git a/Jint/Native/Intl/DisplayNamesConstructor.cs b/Jint/Native/Intl/DisplayNamesConstructor.cs
index 3c392036c2..ddcaad4ed3 100644
--- a/Jint/Native/Intl/DisplayNamesConstructor.cs
+++ b/Jint/Native/Intl/DisplayNamesConstructor.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,11 @@ namespace Jint.Native.Intl;
internal sealed class DisplayNamesConstructor : Constructor
{
private static readonly JsString _functionName = new("DisplayNames");
+ private static readonly HashSet LocaleMatcherValues = ["lookup", "best fit"];
+ private static readonly HashSet TypeValues = ["language", "region", "script", "currency", "calendar", "dateTimeField"];
+ private static readonly HashSet StyleValues = ["long", "short", "narrow"];
+ private static readonly HashSet FallbackValues = ["code", "none"];
+ private static readonly HashSet LanguageDisplayValues = ["dialect", "standard"];
public DisplayNamesConstructor(
Engine engine,
@@ -20,14 +27,149 @@ public DisplayNamesConstructor(
{
_prototype = functionPrototype;
PrototypeObject = new DisplayNamesPrototype(engine, realm, this, objectPrototype);
- _length = new PropertyDescriptor(JsNumber.PositiveZero, PropertyFlag.Configurable);
+ _length = new PropertyDescriptor(2, PropertyFlag.Configurable);
_prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden);
}
+ 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);
+ }
+
public DisplayNamesPrototype PrototypeObject { get; }
+ ///
+ /// Called when Intl.DisplayNames is invoked without `new`.
+ ///
+ protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments)
+ {
+ Throw.TypeError(_realm, "Constructor Intl.DisplayNames requires 'new'");
+ return Undefined;
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.displaynames
+ ///
public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget)
{
- throw new NotImplementedException();
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Step 2: Get prototype from newTarget FIRST (per spec order)
+ var proto = GetPrototypeFromConstructor(newTarget, static intrinsics => intrinsics.DisplayNames.PrototypeObject);
+
+ // Step 3: Resolve locales
+ var requestedLocales = IntlUtilities.CanonicalizeLocaleList(_engine, locales);
+
+ // Step 4: options is required (GetOptionsObject throws for undefined)
+ if (options.IsUndefined())
+ {
+ Throw.TypeError(_realm, "Options argument is required");
+ }
+
+ // Get options object
+ var optionsObj = IntlUtilities.CoerceOptionsToObject(_engine, options);
+
+ // Per spec: Get options in the correct order
+ // Step 8: localeMatcher
+ var localeMatcher = GetStringOption(optionsObj, "localeMatcher", LocaleMatcherValues, "best fit");
+
+ // Step 11: style (comes before type per spec)
+ var style = GetStringOption(optionsObj, "style", StyleValues, "long");
+
+ // Step 13: type (required)
+ var typeValue = optionsObj.Get("type");
+ if (typeValue.IsUndefined())
+ {
+ Throw.TypeError(_realm, "Required option 'type' is undefined");
+ }
+ var type = GetStringOption(optionsObj, "type", TypeValues, "");
+
+ // Step 15: fallback
+ var fallback = GetStringOption(optionsObj, "fallback", FallbackValues, "code");
+
+ // Step 17: languageDisplay (only valid for type: "language")
+ string? languageDisplay = null;
+ if (string.Equals(type, "language", StringComparison.Ordinal))
+ {
+ languageDisplay = GetStringOption(optionsObj, "languageDisplay", LanguageDisplayValues, "dialect");
+ }
+
+ // Resolve locale
+ var availableLocales = IntlUtilities.GetAvailableLocales();
+ var resolvedLocale = ResolveDisplayNamesLocale(_engine, availableLocales, requestedLocales, localeMatcher);
+
+ // Get CultureInfo for the locale
+ var culture = IntlUtilities.GetCultureInfo(resolvedLocale) ?? CultureInfo.InvariantCulture;
+
+ return new JsDisplayNames(
+ _engine,
+ proto,
+ resolvedLocale,
+ type,
+ style,
+ fallback,
+ languageDisplay,
+ culture);
+ }
+
+ 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 static string ResolveDisplayNamesLocale(Engine engine, HashSet availableLocales, List requestedLocales, string localeMatcher)
+ {
+ var resolved = IntlUtilities.ResolveLocale(engine, availableLocales, requestedLocales, localeMatcher, []);
+ return resolved.Locale;
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.displaynames.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");
+
+ 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/DisplayNamesPrototype.cs b/Jint/Native/Intl/DisplayNamesPrototype.cs
index a63a39d45b..a49c14a02c 100644
--- a/Jint/Native/Intl/DisplayNamesPrototype.cs
+++ b/Jint/Native/Intl/DisplayNamesPrototype.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,9 +24,14 @@ public DisplayNamesPrototype(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;
+
+ var properties = new PropertyDictionary(3, checkExistingKeys: false)
{
- ["constructor"] = new PropertyDescriptor(_constructor, true, false, true),
+ ["constructor"] = new PropertyDescriptor(_constructor, PropertyFlag.NonEnumerable),
+ ["of"] = new PropertyDescriptor(new ClrFunction(Engine, "of", Of, 1, LengthFlags), PropertyFlags),
+ ["resolvedOptions"] = new PropertyDescriptor(new ClrFunction(Engine, "resolvedOptions", ResolvedOptions, 0, LengthFlags), PropertyFlags),
};
SetProperties(properties);
@@ -35,4 +41,379 @@ protected override void Initialize()
};
SetSymbols(symbols);
}
+
+ private JsDisplayNames ValidateDisplayNames(JsValue thisObject)
+ {
+ if (thisObject is JsDisplayNames displayNames)
+ {
+ return displayNames;
+ }
+
+ Throw.TypeError(_realm, "Value is not an Intl.DisplayNames");
+ return null!; // Never reached
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.displaynames.prototype.of
+ ///
+ private JsValue Of(JsValue thisObject, JsCallArguments arguments)
+ {
+ var displayNames = ValidateDisplayNames(thisObject);
+ var code = arguments.At(0);
+
+ // code argument is required
+ if (code.IsUndefined())
+ {
+ Throw.TypeError(_realm, "Code argument is required");
+ }
+
+ var codeString = TypeConverter.ToString(code);
+
+ // Validate code based on type
+ if (!ValidateCode(displayNames.DisplayType, codeString))
+ {
+ Throw.RangeError(_realm, $"Invalid code '{codeString}' for type '{displayNames.DisplayType}'");
+ }
+
+ var result = displayNames.Of(codeString);
+
+ return result != null ? new JsString(result) : JsValue.Undefined;
+ }
+
+ private static bool ValidateCode(string type, string code)
+ {
+ if (string.IsNullOrEmpty(code))
+ {
+ return false;
+ }
+
+ return type switch
+ {
+ "language" => IsValidLanguageCode(code),
+ "region" => IsValidRegionCode(code),
+ "script" => IsValidScriptCode(code),
+ "currency" => IsValidCurrencyCode(code),
+ "calendar" => IsValidCalendarCode(code),
+ "dateTimeField" => IsValidDateTimeFieldCode(code),
+ _ => false
+ };
+ }
+
+ private static bool IsValidLanguageCode(string code)
+ {
+ // Language codes must be valid unicode_language_id per ECMA-402
+ // unicode_language_id = unicode_language_subtag (("-" unicode_script_subtag)? ("-" unicode_region_subtag)? | ("-" unicode_region_subtag)) ("-" unicode_variant_subtag)*
+ // https://tc39.es/ecma402/#sec-isstructurallyvalidlanguagetag
+
+ if (string.IsNullOrEmpty(code))
+ {
+ return false;
+ }
+
+ // Reject CLDR-specific syntax not valid in BCP 47
+ if (string.Equals(code, "root", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ // Reject underscores (BCP 47 uses hyphens only)
+ if (code.Contains('_'))
+ {
+ return false;
+ }
+
+ // Cannot start or end with separator
+ if (code[0] == '-' || code[code.Length - 1] == '-')
+ {
+ return false;
+ }
+
+ // Check for empty subtags (consecutive hyphens)
+ if (code.Contains("--"))
+ {
+ return false;
+ }
+
+ // Must contain only valid characters
+ foreach (var c in code)
+ {
+ if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-'))
+ {
+ return false;
+ }
+ }
+
+ var parts = code.Split('-');
+ if (parts.Length == 0)
+ {
+ return false;
+ }
+
+ var firstPart = parts[0];
+
+ // First part must be language subtag: 2-3 or 5-8 letters (4 letters is script, not valid as first)
+ if (firstPart.Length == 1 || firstPart.Length == 4 || firstPart.Length > 8)
+ {
+ return false;
+ }
+
+ // First part must start with letter and be all letters
+ foreach (var c in firstPart)
+ {
+ if (!char.IsLetter(c))
+ {
+ return false;
+ }
+ }
+
+ // No extensions or private use allowed for unicode_language_id
+ var hasScript = false;
+ var hasRegion = false;
+ var seenVariants = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ for (var i = 1; i < parts.Length; i++)
+ {
+ var part = parts[i];
+
+ if (part.Length == 0)
+ {
+ return false; // Empty subtag
+ }
+
+ // Check for singleton (extension marker) - not allowed
+ if (part.Length == 1)
+ {
+ return false;
+ }
+
+ // 2-character subtag starting with letter: region
+ if (part.Length == 2 && char.IsLetter(part[0]))
+ {
+ if (hasRegion || seenVariants.Count > 0)
+ {
+ return false; // Duplicate region or region after variant
+ }
+ if (!char.IsLetter(part[1]))
+ {
+ return false;
+ }
+ hasRegion = true;
+ }
+ // 3-digit subtag: UN M.49 region code
+ else if (part.Length == 3 && char.IsDigit(part[0]) && char.IsDigit(part[1]) && char.IsDigit(part[2]))
+ {
+ if (hasRegion || seenVariants.Count > 0)
+ {
+ return false;
+ }
+ hasRegion = true;
+ }
+ // 3-character subtag starting with digit: invalid for region (must be all digits)
+ else if (part.Length == 3 && char.IsDigit(part[0]))
+ {
+ // Must be all digits for region, otherwise invalid
+ if (!char.IsDigit(part[1]) || !char.IsDigit(part[2]))
+ {
+ return false;
+ }
+ }
+ // 4-character subtag all letters: script
+ else if (part.Length == 4)
+ {
+ var allLetters = true;
+ foreach (var c in part)
+ {
+ if (!char.IsLetter(c))
+ {
+ allLetters = false;
+ break;
+ }
+ }
+
+ if (allLetters)
+ {
+ if (hasScript || hasRegion || seenVariants.Count > 0)
+ {
+ return false; // Duplicate script or script after region/variant
+ }
+ hasScript = true;
+ }
+ else if (char.IsDigit(part[0]))
+ {
+ // 4-char variant starting with digit
+ if (seenVariants.Contains(part))
+ {
+ return false; // Duplicate variant
+ }
+ seenVariants.Add(part);
+ }
+ else
+ {
+ return false; // Invalid 4-char subtag
+ }
+ }
+ // 5-8 character subtag: variant
+ else if (part.Length >= 5 && part.Length <= 8)
+ {
+ // Variant subtags are alphanumeric
+ foreach (var c in part)
+ {
+ if (!char.IsLetterOrDigit(c))
+ {
+ return false;
+ }
+ }
+ if (seenVariants.Contains(part))
+ {
+ return false; // Duplicate variant
+ }
+ seenVariants.Add(part);
+ }
+ else
+ {
+ return false; // Invalid subtag length
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsValidRegionCode(string code)
+ {
+ // Region codes: 2 letter codes (US) or 3 digit codes (001)
+ if (code.Length == 2)
+ {
+ return char.IsLetter(code[0]) && char.IsLetter(code[1]);
+ }
+
+ if (code.Length == 3)
+ {
+ return char.IsDigit(code[0]) && char.IsDigit(code[1]) && char.IsDigit(code[2]);
+ }
+
+ return false;
+ }
+
+ private static bool IsValidScriptCode(string code)
+ {
+ // Script codes: 4 letter codes (Latn, Cyrl)
+ if (code.Length != 4)
+ {
+ return false;
+ }
+
+ foreach (var c in code)
+ {
+ if (!char.IsLetter(c))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsValidCurrencyCode(string code)
+ {
+ // Currency codes: 3 letter codes (USD, EUR)
+ if (code.Length != 3)
+ {
+ return false;
+ }
+
+ foreach (var c in code)
+ {
+ if (!char.IsLetter(c))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsValidCalendarCode(string code)
+ {
+ // Calendar codes must follow Unicode calendar identifier format:
+ // unicode_calendar_identifier = unicode_alpha (3*8alphanum) *("-" (3*8alphanum))
+ // Each segment must be 3-8 alphanumeric characters (ASCII only)
+ // Segments are separated by hyphens (not underscores)
+ // https://unicode.org/reports/tr35/#Unicode_calendar_identifier
+
+ if (string.IsNullOrEmpty(code))
+ {
+ return false;
+ }
+
+ // Cannot start or end with separator
+ if (code[0] == '-' || code[0] == '_' || code[code.Length - 1] == '-' || code[code.Length - 1] == '_')
+ {
+ return false;
+ }
+
+ // Cannot contain spaces or underscores
+ foreach (var c in code)
+ {
+ if (char.IsWhiteSpace(c) || c == '_')
+ {
+ return false;
+ }
+ }
+
+ var segments = code.Split('-');
+
+ foreach (var segment in segments)
+ {
+ // Each segment must be 3-8 characters
+ if (segment.Length < 3 || segment.Length > 8)
+ {
+ return false;
+ }
+
+ // Each segment must be ASCII alphanumeric only
+ foreach (var c in segment)
+ {
+ if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')))
+ {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsValidDateTimeFieldCode(string code)
+ {
+ // DateTime field codes
+ return code switch
+ {
+ "era" or "year" or "quarter" or "month" or "weekOfYear" or
+ "weekday" or "day" or "dayPeriod" or "hour" or "minute" or
+ "second" or "timeZoneName" => true,
+ _ => false
+ };
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.displaynames.prototype.resolvedoptions
+ ///
+ private JsObject ResolvedOptions(JsValue thisObject, JsCallArguments arguments)
+ {
+ var displayNames = ValidateDisplayNames(thisObject);
+
+ var result = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject);
+
+ result.CreateDataPropertyOrThrow("locale", displayNames.Locale);
+ result.CreateDataPropertyOrThrow("style", displayNames.Style);
+ result.CreateDataPropertyOrThrow("type", displayNames.DisplayType);
+ result.CreateDataPropertyOrThrow("fallback", displayNames.Fallback);
+
+ if (displayNames.LanguageDisplay != null)
+ {
+ result.CreateDataPropertyOrThrow("languageDisplay", displayNames.LanguageDisplay);
+ }
+
+ return result;
+ }
}
diff --git a/Jint/Native/Intl/DurationFormatConstructor.cs b/Jint/Native/Intl/DurationFormatConstructor.cs
new file mode 100644
index 0000000000..8926e89844
--- /dev/null
+++ b/Jint/Native/Intl/DurationFormatConstructor.cs
@@ -0,0 +1,307 @@
+#pragma warning disable CA1859 // Use concrete types when possible for improved performance -- ClrFunction requires JsValue
+
+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;
+
+///
+/// https://tc39.es/proposal-intl-duration-format/
+///
+internal sealed class DurationFormatConstructor : Constructor
+{
+ private static readonly JsString _functionName = new("DurationFormat");
+ private static readonly string[] LocaleMatcherValues = ["lookup", "best fit"];
+ private static readonly string[] StyleValues = ["long", "short", "narrow", "digital"];
+ private static readonly string[] UnitStyleValues = ["long", "short", "narrow"];
+ private static readonly string[] UnitStyleWithNumericValues = ["long", "short", "narrow", "numeric", "2-digit"];
+ private static readonly string[] SubSecondStyleValues = ["long", "short", "narrow", "numeric"];
+ private static readonly string[] DisplayValues = ["auto", "always"];
+
+ public DurationFormatConstructor(
+ Engine engine,
+ Realm realm,
+ FunctionPrototype functionPrototype,
+ ObjectPrototype objectPrototype) : base(engine, realm, _functionName)
+ {
+ _prototype = functionPrototype;
+ PrototypeObject = new DurationFormatPrototype(engine, realm, this, objectPrototype);
+ _length = new PropertyDescriptor(JsNumber.PositiveZero, PropertyFlag.Configurable);
+ _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden);
+ }
+
+ 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);
+ }
+
+ public DurationFormatPrototype PrototypeObject { get; }
+
+ ///
+ /// Called when Intl.DurationFormat is invoked without `new`.
+ ///
+ protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments)
+ {
+ Throw.TypeError(_realm, "Constructor Intl.DurationFormat requires 'new'");
+ return Undefined;
+ }
+
+ ///
+ /// https://tc39.es/proposal-intl-duration-format/#sec-intl.durationformat
+ ///
+ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget)
+ {
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Get options object (strict - throws TypeError for non-object)
+ var optionsObj = IntlUtilities.GetOptionsObject(_engine, options);
+
+ // Per spec steps 5, 6, 13: Get options in the correct order
+ // Step 5: localeMatcher
+ var localeMatcher = GetStringOption(optionsObj, "localeMatcher", LocaleMatcherValues, "best fit");
+
+ // Step 6: numberingSystem (must be read before style)
+ var numberingSystem = GetNumberingSystemOption(optionsObj);
+
+ // Step 13: style (must be read after localeMatcher and numberingSystem, before unit options)
+ var style = GetStringOption(optionsObj, "style", StyleValues, "short");
+
+ // Resolve locale (don't re-read localeMatcher from options)
+ var requestedLocales = IntlUtilities.CanonicalizeLocaleList(_engine, locales);
+ var availableLocales = IntlUtilities.GetAvailableLocales();
+ var resolved = IntlUtilities.ResolveLocale(_engine, availableLocales, requestedLocales, localeMatcher, []);
+
+ // Determine base style based on overall style (for date units)
+ var baseStyle = style switch
+ {
+ "digital" => "short",
+ _ => style // "long", "short", "narrow"
+ };
+
+ // Per spec, when style is "short", all units default to "short"
+ var baseTimeStyle = style switch
+ {
+ "long" => "long",
+ "short" => "short",
+ "narrow" => "narrow",
+ "digital" => "numeric",
+ _ => "short"
+ };
+ var isDigital = string.Equals(style, "digital", StringComparison.Ordinal);
+
+ // Helper to check if a style is numeric-like
+ bool IsNumericLike(string s) =>
+ string.Equals(s, "numeric", StringComparison.Ordinal) ||
+ string.Equals(s, "2-digit", StringComparison.Ordinal);
+
+ // Helper to check if a style is numeric-like or fractional (for sub-second units)
+ bool IsNumericLikeOrFractional(string s) =>
+ IsNumericLike(s) ||
+ string.Equals(s, "fractional", StringComparison.Ordinal);
+
+ // Get unit style options with cascading defaults per spec (GetDurationUnitOptions)
+ // Per Table 1: Years/months/weeks/days use baseStyle
+ var yearsStyle = GetStringOption(optionsObj, "years", UnitStyleValues, baseStyle);
+ var yearsDisplay = GetStringOption(optionsObj, "yearsDisplay", DisplayValues, "auto");
+
+ var monthsStyle = GetStringOption(optionsObj, "months", UnitStyleValues, baseStyle);
+ var monthsDisplay = GetStringOption(optionsObj, "monthsDisplay", DisplayValues, "auto");
+
+ var weeksStyle = GetStringOption(optionsObj, "weeks", UnitStyleValues, baseStyle);
+ var weeksDisplay = GetStringOption(optionsObj, "weeksDisplay", DisplayValues, "auto");
+
+ var daysStyle = GetStringOption(optionsObj, "days", UnitStyleValues, baseStyle);
+ var daysDisplay = GetStringOption(optionsObj, "daysDisplay", DisplayValues, "auto");
+
+ // Per Table 1: Hours/minutes/seconds/sub-seconds use baseTimeStyle
+ // Hours can use numeric/2-digit
+ var hoursDefault = isDigital ? "numeric" : baseTimeStyle;
+ var hoursStyle = GetStringOption(optionsObj, "hours", UnitStyleWithNumericValues, hoursDefault);
+ var hoursDisplay = GetStringOption(optionsObj, "hoursDisplay", DisplayValues, "auto");
+ var prevStyle = hoursStyle;
+
+ // Minutes/seconds: default to 2-digit if previous is numeric-like
+ // Per spec: if previous is numeric/2-digit, current cannot be long/short/narrow
+ var minutesDefault = IsNumericLike(prevStyle) ? "2-digit" : (isDigital ? "numeric" : baseTimeStyle);
+ var minutesStyle = GetStringOption(optionsObj, "minutes", UnitStyleWithNumericValues, minutesDefault);
+ var minutesDisplay = GetStringOption(optionsObj, "minutesDisplay", DisplayValues, "auto");
+ if (IsNumericLike(prevStyle) && !IsNumericLike(minutesStyle))
+ {
+ Throw.RangeError(_realm, "minutes style must be numeric or 2-digit when hours uses numeric or 2-digit");
+ }
+ prevStyle = minutesStyle;
+
+ var secondsDefault = IsNumericLike(prevStyle) ? "2-digit" : (isDigital ? "numeric" : baseTimeStyle);
+ var secondsStyle = GetStringOption(optionsObj, "seconds", UnitStyleWithNumericValues, secondsDefault);
+ var secondsDisplay = GetStringOption(optionsObj, "secondsDisplay", DisplayValues, "auto");
+ if (IsNumericLike(prevStyle) && !IsNumericLike(secondsStyle))
+ {
+ Throw.RangeError(_realm, "seconds style must be numeric or 2-digit when minutes uses numeric or 2-digit");
+ }
+ prevStyle = secondsStyle;
+
+ // Sub-second units: default to numeric if previous is numeric-like
+ var millisecondsDefault = IsNumericLike(prevStyle) ? "numeric" : (isDigital ? "numeric" : baseTimeStyle);
+ var millisecondsStyle = GetStringOption(optionsObj, "milliseconds", SubSecondStyleValues, millisecondsDefault);
+ var millisecondsDisplay = GetStringOption(optionsObj, "millisecondsDisplay", DisplayValues, "auto");
+ if (IsNumericLike(prevStyle) && !IsNumericLikeOrFractional(millisecondsStyle))
+ {
+ Throw.RangeError(_realm, "milliseconds style must be numeric when seconds uses numeric or 2-digit");
+ }
+ prevStyle = millisecondsStyle;
+
+ var microsecondsDefault = IsNumericLike(prevStyle) ? "numeric" : (isDigital ? "numeric" : baseTimeStyle);
+ var microsecondsStyle = GetStringOption(optionsObj, "microseconds", SubSecondStyleValues, microsecondsDefault);
+ var microsecondsDisplay = GetStringOption(optionsObj, "microsecondsDisplay", DisplayValues, "auto");
+ if (IsNumericLikeOrFractional(prevStyle) && !IsNumericLikeOrFractional(microsecondsStyle))
+ {
+ Throw.RangeError(_realm, "microseconds style must be numeric when milliseconds uses numeric");
+ }
+ prevStyle = microsecondsStyle;
+
+ var nanosecondsDefault = IsNumericLike(prevStyle) ? "numeric" : (isDigital ? "numeric" : baseTimeStyle);
+ var nanosecondsStyle = GetStringOption(optionsObj, "nanoseconds", SubSecondStyleValues, nanosecondsDefault);
+ var nanosecondsDisplay = GetStringOption(optionsObj, "nanosecondsDisplay", DisplayValues, "auto");
+ if (IsNumericLikeOrFractional(prevStyle) && !IsNumericLikeOrFractional(nanosecondsStyle))
+ {
+ Throw.RangeError(_realm, "nanoseconds style must be numeric when microseconds uses numeric");
+ }
+
+ // Get fractionalDigits option
+ int? fractionalDigits = null;
+ var fractionalDigitsValue = optionsObj.Get("fractionalDigits");
+ if (!fractionalDigitsValue.IsUndefined())
+ {
+ var fd = TypeConverter.ToNumber(fractionalDigitsValue);
+ if (double.IsNaN(fd) || fd < 0 || fd > 9)
+ {
+ Throw.RangeError(_realm, "fractionalDigits must be between 0 and 9");
+ }
+ fractionalDigits = (int) System.Math.Floor(fd);
+ }
+
+ // Get CultureInfo for the locale
+ var culture = IntlUtilities.GetCultureInfo(resolved.Locale) ?? CultureInfo.InvariantCulture;
+
+ // Get prototype from newTarget (for cross-realm construction)
+ var proto = GetPrototypeFromConstructor(newTarget, static intrinsics => intrinsics.DurationFormat.PrototypeObject);
+
+ return new JsDurationFormat(
+ _engine,
+ proto,
+ resolved.Locale,
+ style,
+ numberingSystem,
+ culture,
+ yearsStyle,
+ monthsStyle,
+ weeksStyle,
+ daysStyle,
+ hoursStyle,
+ minutesStyle,
+ secondsStyle,
+ millisecondsStyle,
+ microsecondsStyle,
+ nanosecondsStyle,
+ yearsDisplay,
+ monthsDisplay,
+ weeksDisplay,
+ daysDisplay,
+ hoursDisplay,
+ minutesDisplay,
+ secondsDisplay,
+ millisecondsDisplay,
+ microsecondsDisplay,
+ nanosecondsDisplay,
+ fractionalDigits);
+ }
+
+ private string GetStringOption(ObjectInstance options, string property, string[]? values, string fallback)
+ {
+ var value = options.Get(property);
+ if (value.IsUndefined())
+ {
+ return fallback;
+ }
+
+ var stringValue = TypeConverter.ToString(value);
+
+ if (values != null && values.Length > 0)
+ {
+ var found = false;
+ foreach (var allowed in values)
+ {
+ if (string.Equals(stringValue, allowed, StringComparison.Ordinal))
+ {
+ found = true;
+ break;
+ }
+ }
+
+ if (!found)
+ {
+ Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option '{property}'");
+ }
+ }
+
+ return stringValue;
+ }
+
+ private string GetNumberingSystemOption(ObjectInstance options)
+ {
+ var value = options.Get("numberingSystem");
+ if (value.IsUndefined())
+ {
+ return "latn"; // Default
+ }
+
+ var stringValue = TypeConverter.ToString(value);
+
+ // Validate against Unicode Locale Identifier type nonterminal
+ // Pattern: (3*8alphanum) *("-" (3*8alphanum))
+ if (!IntlUtilities.IsValidUnicodeExtensionValue(stringValue))
+ {
+ Throw.RangeError(_realm, $"Invalid numbering system: {stringValue}");
+ }
+
+ return stringValue;
+ }
+
+ ///
+ /// https://tc39.es/proposal-intl-duration-format/#sec-intl.durationformat.supportedlocalesof
+ ///
+ private JsValue 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");
+
+ var supported = new List();
+ 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/DurationFormatPrototype.cs b/Jint/Native/Intl/DurationFormatPrototype.cs
new file mode 100644
index 0000000000..b1caa926d1
--- /dev/null
+++ b/Jint/Native/Intl/DurationFormatPrototype.cs
@@ -0,0 +1,262 @@
+#pragma warning disable CA1859 // Use concrete types when possible for improved performance -- prototype methods return JsValue
+
+using Jint.Native.Object;
+using Jint.Native.Symbol;
+using Jint.Runtime;
+using Jint.Runtime.Descriptors;
+using Jint.Runtime.Interop;
+
+namespace Jint.Native.Intl;
+
+///
+/// https://tc39.es/proposal-intl-duration-format/
+///
+internal sealed class DurationFormatPrototype : Prototype
+{
+ private readonly DurationFormatConstructor _constructor;
+
+ public DurationFormatPrototype(
+ Engine engine,
+ Realm realm,
+ DurationFormatConstructor constructor,
+ ObjectPrototype objectPrototype) : base(engine, realm)
+ {
+ _prototype = objectPrototype;
+ _constructor = constructor;
+ }
+
+ protected override void Initialize()
+ {
+ const PropertyFlag LengthFlags = PropertyFlag.Configurable;
+ const PropertyFlag PropertyFlags = PropertyFlag.Writable | PropertyFlag.Configurable;
+
+ var properties = new PropertyDictionary(4, checkExistingKeys: false)
+ {
+ ["constructor"] = new PropertyDescriptor(_constructor, PropertyFlag.NonEnumerable),
+ ["format"] = new PropertyDescriptor(new ClrFunction(Engine, "format", Format, 1, LengthFlags), PropertyFlags),
+ ["formatToParts"] = new PropertyDescriptor(new ClrFunction(Engine, "formatToParts", FormatToParts, 1, LengthFlags), PropertyFlags),
+ ["resolvedOptions"] = new PropertyDescriptor(new ClrFunction(Engine, "resolvedOptions", ResolvedOptions, 0, LengthFlags), PropertyFlags),
+ };
+ SetProperties(properties);
+
+ var symbols = new SymbolDictionary(1)
+ {
+ [GlobalSymbolRegistry.ToStringTag] = new("Intl.DurationFormat", PropertyFlag.Configurable)
+ };
+ SetSymbols(symbols);
+ }
+
+ private JsDurationFormat ValidateDurationFormat(JsValue thisObject)
+ {
+ if (thisObject is JsDurationFormat durationFormat)
+ {
+ return durationFormat;
+ }
+
+ Throw.TypeError(_realm, "Value is not an Intl.DurationFormat");
+ return null!; // Never reached
+ }
+
+ ///
+ /// https://tc39.es/proposal-intl-duration-format/#sec-intl.durationformat.prototype.format
+ ///
+ private JsValue Format(JsValue thisObject, JsCallArguments arguments)
+ {
+ var durationFormat = ValidateDurationFormat(thisObject);
+ var duration = arguments.At(0);
+
+ var durationRecord = ToDurationRecord(duration);
+ return durationFormat.Format(durationRecord);
+ }
+
+ ///
+ /// https://tc39.es/proposal-intl-duration-format/#sec-intl.durationformat.prototype.formattoparts
+ ///
+ private JsValue FormatToParts(JsValue thisObject, JsCallArguments arguments)
+ {
+ var durationFormat = ValidateDurationFormat(thisObject);
+ var duration = arguments.At(0);
+
+ var durationRecord = ToDurationRecord(duration);
+ return durationFormat.FormatToParts(_engine, durationRecord);
+ }
+
+ ///
+ /// https://tc39.es/proposal-intl-duration-format/#sec-intl.durationformat.prototype.resolvedoptions
+ ///
+ private JsValue ResolvedOptions(JsValue thisObject, JsCallArguments arguments)
+ {
+ var durationFormat = ValidateDurationFormat(thisObject);
+
+ var result = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject);
+
+ result.CreateDataPropertyOrThrow("locale", durationFormat.Locale);
+ result.CreateDataPropertyOrThrow("numberingSystem", durationFormat.NumberingSystem);
+ result.CreateDataPropertyOrThrow("style", durationFormat.Style);
+
+ // Unit styles
+ result.CreateDataPropertyOrThrow("years", durationFormat.YearsStyle);
+ result.CreateDataPropertyOrThrow("yearsDisplay", durationFormat.YearsDisplay);
+ result.CreateDataPropertyOrThrow("months", durationFormat.MonthsStyle);
+ result.CreateDataPropertyOrThrow("monthsDisplay", durationFormat.MonthsDisplay);
+ result.CreateDataPropertyOrThrow("weeks", durationFormat.WeeksStyle);
+ result.CreateDataPropertyOrThrow("weeksDisplay", durationFormat.WeeksDisplay);
+ result.CreateDataPropertyOrThrow("days", durationFormat.DaysStyle);
+ result.CreateDataPropertyOrThrow("daysDisplay", durationFormat.DaysDisplay);
+ result.CreateDataPropertyOrThrow("hours", durationFormat.HoursStyle);
+ result.CreateDataPropertyOrThrow("hoursDisplay", durationFormat.HoursDisplay);
+ result.CreateDataPropertyOrThrow("minutes", durationFormat.MinutesStyle);
+ result.CreateDataPropertyOrThrow("minutesDisplay", durationFormat.MinutesDisplay);
+ result.CreateDataPropertyOrThrow("seconds", durationFormat.SecondsStyle);
+ result.CreateDataPropertyOrThrow("secondsDisplay", durationFormat.SecondsDisplay);
+ result.CreateDataPropertyOrThrow("milliseconds", durationFormat.MillisecondsStyle);
+ result.CreateDataPropertyOrThrow("millisecondsDisplay", durationFormat.MillisecondsDisplay);
+ result.CreateDataPropertyOrThrow("microseconds", durationFormat.MicrosecondsStyle);
+ result.CreateDataPropertyOrThrow("microsecondsDisplay", durationFormat.MicrosecondsDisplay);
+ result.CreateDataPropertyOrThrow("nanoseconds", durationFormat.NanosecondsStyle);
+ result.CreateDataPropertyOrThrow("nanosecondsDisplay", durationFormat.NanosecondsDisplay);
+
+ // Fractional digits
+ if (durationFormat.FractionalDigits.HasValue)
+ {
+ result.CreateDataPropertyOrThrow("fractionalDigits", durationFormat.FractionalDigits.Value);
+ }
+
+ return result;
+ }
+
+ private static readonly string[] DurationProperties =
+ [
+ "years", "months", "weeks", "days", "hours", "minutes",
+ "seconds", "milliseconds", "microseconds", "nanoseconds"
+ ];
+
+ private JsDurationFormat.DurationRecord ToDurationRecord(JsValue value)
+ {
+ // Per spec: if input is a string, try to parse it as a duration
+ if (value.IsString())
+ {
+ // String durations not yet supported - throw RangeError
+ Throw.RangeError(_realm, "Duration string parsing is not supported");
+ }
+
+ if (!value.IsObject())
+ {
+ Throw.TypeError(_realm, "Duration must be an object");
+ }
+
+ var obj = value.AsObject();
+
+ // Check if at least one duration property is defined and not undefined
+ var hasDefinedProperty = false;
+ foreach (var prop in DurationProperties)
+ {
+ var propValue = obj.Get(prop);
+ if (!propValue.IsUndefined())
+ {
+ hasDefinedProperty = true;
+ break;
+ }
+ }
+
+ if (!hasDefinedProperty)
+ {
+ Throw.TypeError(_realm, "Duration must have at least one duration property defined");
+ }
+
+ var record = new JsDurationFormat.DurationRecord();
+
+ record.Years = GetDurationComponent(obj, "years");
+ record.Months = GetDurationComponent(obj, "months");
+ record.Weeks = GetDurationComponent(obj, "weeks");
+ record.Days = GetDurationComponent(obj, "days");
+ record.Hours = GetDurationComponent(obj, "hours");
+ record.Minutes = GetDurationComponent(obj, "minutes");
+ record.Seconds = GetDurationComponent(obj, "seconds");
+ record.Milliseconds = GetDurationComponent(obj, "milliseconds");
+ record.Microseconds = GetDurationComponent(obj, "microseconds");
+ record.Nanoseconds = GetDurationComponent(obj, "nanoseconds");
+
+ // Validate the duration record per spec (IsValidDurationRecord)
+ ValidateDurationRecord(record);
+
+ return record;
+ }
+
+ private void ValidateDurationRecord(JsDurationFormat.DurationRecord record)
+ {
+ const double MaxYearsMonthsWeeks = 4294967296.0; // 2^32
+ const double MaxSafeInteger = 9007199254740992.0; // 2^53
+
+ // Check if years, months, weeks are in valid range
+ if (System.Math.Abs(record.Years) >= MaxYearsMonthsWeeks)
+ {
+ Throw.RangeError(_realm, "years value out of range");
+ }
+ if (System.Math.Abs(record.Months) >= MaxYearsMonthsWeeks)
+ {
+ Throw.RangeError(_realm, "months value out of range");
+ }
+ if (System.Math.Abs(record.Weeks) >= MaxYearsMonthsWeeks)
+ {
+ Throw.RangeError(_realm, "weeks value out of range");
+ }
+
+ // Per spec: normalizedSeconds = days × 86400 + hours × 3600 + minutes × 60 + seconds +
+ // milliseconds × 10^-3 + microseconds × 10^-6 + nanoseconds × 10^-9
+ // If abs(normalizedSeconds) >= 2^53, throw RangeError
+ var normalizedSeconds =
+ record.Days * 86400 +
+ record.Hours * 3600 +
+ record.Minutes * 60 +
+ record.Seconds +
+ record.Milliseconds * 1e-3 +
+ record.Microseconds * 1e-6 +
+ record.Nanoseconds * 1e-9;
+
+ if (System.Math.Abs(normalizedSeconds) >= MaxSafeInteger)
+ {
+ Throw.RangeError(_realm, "Duration time values out of range");
+ }
+
+ // Check for mixed positive and negative values
+ var hasPositive = record.Years > 0 || record.Months > 0 || record.Weeks > 0 ||
+ record.Days > 0 || record.Hours > 0 || record.Minutes > 0 ||
+ record.Seconds > 0 || record.Milliseconds > 0 ||
+ record.Microseconds > 0 || record.Nanoseconds > 0;
+
+ var hasNegative = record.Years < 0 || record.Months < 0 || record.Weeks < 0 ||
+ record.Days < 0 || record.Hours < 0 || record.Minutes < 0 ||
+ record.Seconds < 0 || record.Milliseconds < 0 ||
+ record.Microseconds < 0 || record.Nanoseconds < 0;
+
+ if (hasPositive && hasNegative)
+ {
+ Throw.RangeError(_realm, "Duration cannot have mixed positive and negative values");
+ }
+ }
+
+ private double GetDurationComponent(ObjectInstance obj, string property)
+ {
+ var value = obj.Get(property);
+ if (value.IsUndefined())
+ {
+ return 0;
+ }
+
+ var number = TypeConverter.ToNumber(value);
+ if (double.IsNaN(number) || double.IsInfinity(number))
+ {
+ Throw.RangeError(_realm, $"Invalid value for {property}");
+ }
+
+ // Per spec: duration properties must be integers
+ var truncated = System.Math.Truncate(number);
+ if (number != truncated)
+ {
+ Throw.RangeError(_realm, $"Duration property {property} must be an integer");
+ }
+
+ return truncated;
+ }
+}
diff --git a/Jint/Native/Intl/IntlInstance.cs b/Jint/Native/Intl/IntlInstance.cs
index e3acade5e4..8cf253857f 100644
--- a/Jint/Native/Intl/IntlInstance.cs
+++ b/Jint/Native/Intl/IntlInstance.cs
@@ -32,13 +32,18 @@ protected override void Initialize()
{
const PropertyFlag PropertyFlags = PropertyFlag.Writable | PropertyFlag.Configurable;
- var properties = new PropertyDictionary(4, checkExistingKeys: false)
+ var properties = new PropertyDictionary(13, checkExistingKeys: false)
{
["Collator"] = new(_realm.Intrinsics.Collator, PropertyFlags),
["DateTimeFormat"] = new(_realm.Intrinsics.DateTimeFormat, PropertyFlags),
+ ["DisplayNames"] = new(_realm.Intrinsics.DisplayNames, PropertyFlags),
+ ["DurationFormat"] = new(_realm.Intrinsics.DurationFormat, PropertyFlags),
+ ["ListFormat"] = new(_realm.Intrinsics.ListFormat, PropertyFlags),
["Locale"] = new(_realm.Intrinsics.Locale, PropertyFlags),
["NumberFormat"] = new(_realm.Intrinsics.NumberFormat, PropertyFlags),
["PluralRules"] = new(_realm.Intrinsics.PluralRules, PropertyFlags),
+ ["RelativeTimeFormat"] = new(_realm.Intrinsics.RelativeTimeFormat, PropertyFlags),
+ ["Segmenter"] = new(_realm.Intrinsics.Segmenter, 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/JsDisplayNames.cs b/Jint/Native/Intl/JsDisplayNames.cs
new file mode 100644
index 0000000000..cad2d94d06
--- /dev/null
+++ b/Jint/Native/Intl/JsDisplayNames.cs
@@ -0,0 +1,245 @@
+using System.Globalization;
+using Jint.Native.Object;
+
+namespace Jint.Native.Intl;
+
+///
+/// https://tc39.es/ecma402/#sec-displaynames-objects
+/// Represents an Intl.DisplayNames instance for locale-aware display name resolution.
+///
+internal sealed class JsDisplayNames : ObjectInstance
+{
+ internal JsDisplayNames(
+ Engine engine,
+ ObjectInstance prototype,
+ string locale,
+ string type,
+ string style,
+ string fallback,
+ string? languageDisplay,
+ CultureInfo cultureInfo) : base(engine)
+ {
+ _prototype = prototype;
+ Locale = locale;
+ DisplayType = type;
+ Style = style;
+ Fallback = fallback;
+ LanguageDisplay = languageDisplay;
+ CultureInfo = cultureInfo;
+ }
+
+ ///
+ /// The locale used for display names.
+ ///
+ internal string Locale { get; }
+
+ ///
+ /// The type of display names: "language", "region", "script", "currency", "calendar", "dateTimeField".
+ ///
+ internal string DisplayType { get; }
+
+ ///
+ /// The style: "long", "short", or "narrow".
+ ///
+ internal string Style { get; }
+
+ ///
+ /// The fallback behavior: "code" or "none".
+ ///
+ internal string Fallback { get; }
+
+ ///
+ /// For language display names: "dialect" or "standard".
+ ///
+ internal string? LanguageDisplay { get; }
+
+ ///
+ /// The .NET CultureInfo for locale-specific formatting.
+ ///
+ internal CultureInfo CultureInfo { get; }
+
+ ///
+ /// Returns the display name for the given code.
+ ///
+ internal string? Of(string code)
+ {
+ return DisplayType switch
+ {
+ "language" => GetLanguageDisplayName(code),
+ "region" => GetRegionDisplayName(code),
+ "script" => GetScriptDisplayName(code),
+ "currency" => GetCurrencyDisplayName(code),
+ "calendar" => GetCalendarDisplayName(code),
+ "dateTimeField" => GetDateTimeFieldDisplayName(code),
+ _ => null
+ };
+ }
+
+ private string? GetLanguageDisplayName(string code)
+ {
+ try
+ {
+ // Try to get CultureInfo for the language code
+ var culture = CultureInfo.GetCultureInfo(code.Replace('_', '-'));
+
+ // Use native name if available and locale-appropriate
+ if (string.Equals(CultureInfo.TwoLetterISOLanguageName, culture.TwoLetterISOLanguageName, StringComparison.Ordinal))
+ {
+ return culture.NativeName;
+ }
+
+ // In English locale, use EnglishName; otherwise use DisplayName
+ if (Locale.StartsWith("en", StringComparison.OrdinalIgnoreCase))
+ {
+ return culture.EnglishName;
+ }
+
+ return culture.DisplayName;
+ }
+ catch (CultureNotFoundException)
+ {
+ return GetFallbackValue(code);
+ }
+ }
+
+ private string? GetRegionDisplayName(string code)
+ {
+ try
+ {
+ // RegionInfo expects a country/region code
+ var region = new RegionInfo(code);
+
+ if (Locale.StartsWith("en", StringComparison.OrdinalIgnoreCase))
+ {
+ return region.EnglishName;
+ }
+
+ return region.DisplayName;
+ }
+ catch
+ {
+ return GetFallbackValue(code);
+ }
+ }
+
+ private string? GetScriptDisplayName(string code)
+ {
+ // .NET doesn't have built-in script names, provide common ones
+ var name = code.ToUpperInvariant() switch
+ {
+ "LATN" => "Latin",
+ "CYRL" => "Cyrillic",
+ "ARAB" => "Arabic",
+ "HANS" => "Simplified Han",
+ "HANT" => "Traditional Han",
+ "DEVA" => "Devanagari",
+ "GREK" => "Greek",
+ "HEBR" => "Hebrew",
+ "JPAN" => "Japanese",
+ "KORE" => "Korean",
+ "THAI" => "Thai",
+ "BENG" => "Bengali",
+ "GURU" => "Gurmukhi",
+ "GUJR" => "Gujarati",
+ "ORYA" => "Oriya",
+ "TAML" => "Tamil",
+ "TELU" => "Telugu",
+ "KNDA" => "Kannada",
+ "MLYM" => "Malayalam",
+ "SINH" => "Sinhala",
+ "MYMR" => "Myanmar",
+ "GEOR" => "Georgian",
+ "ARMN" => "Armenian",
+ "ETHI" => "Ethiopic",
+ "KHMR" => "Khmer",
+ "TIBT" => "Tibetan",
+ "MONG" => "Mongolian",
+ _ => null
+ };
+
+ return name ?? GetFallbackValue(code);
+ }
+
+ private string? GetCurrencyDisplayName(string code)
+ {
+ // Use CLDR provider to get currency display name
+ var cldrProvider = _engine.Options.Intl.CldrProvider;
+ var name = cldrProvider.GetCurrencyDisplayName(Locale, code);
+
+ return name ?? GetFallbackValue(code);
+ }
+
+ private string? GetCalendarDisplayName(string code)
+ {
+ var normalizedCode = code.ToLowerInvariant();
+
+ // Only return display names for calendars we actually support
+ // This ensures consistency with Intl.supportedValuesOf("calendar")
+ var supportedCalendars = _engine.Options.Intl.CldrProvider.GetSupportedCalendars();
+ var isSupported = false;
+ foreach (var supported in supportedCalendars)
+ {
+ if (string.Equals(supported, normalizedCode, StringComparison.OrdinalIgnoreCase) ||
+ (string.Equals(normalizedCode, "gregorian", StringComparison.Ordinal) && string.Equals(supported, "gregory", StringComparison.Ordinal)))
+ {
+ isSupported = true;
+ break;
+ }
+ }
+
+ if (!isSupported)
+ {
+ return GetFallbackValue(code);
+ }
+
+ var name = normalizedCode switch
+ {
+ "gregory" or "gregorian" => "Gregorian Calendar",
+ "buddhist" => "Buddhist Calendar",
+ "chinese" => "Chinese Calendar",
+ "coptic" => "Coptic Calendar",
+ "dangi" => "Dangi Calendar",
+ "ethioaa" => "Ethiopic Amete Alem Calendar",
+ "ethiopic" => "Ethiopic Calendar",
+ "hebrew" => "Hebrew Calendar",
+ "indian" => "Indian National Calendar",
+ "islamic-umalqura" => "Islamic (Umm al-Qura) Calendar",
+ "islamic-tbla" => "Islamic (tabular, Thursday epoch) Calendar",
+ "islamic-civil" => "Islamic (civil) Calendar",
+ "iso8601" => "ISO-8601 Calendar",
+ "japanese" => "Japanese Calendar",
+ "persian" => "Persian Calendar",
+ "roc" => "Minguo Calendar",
+ _ => null
+ };
+
+ return name ?? GetFallbackValue(code);
+ }
+
+ private string? GetDateTimeFieldDisplayName(string code)
+ {
+ var name = code.ToLowerInvariant() switch
+ {
+ "era" => "era",
+ "year" => "year",
+ "quarter" => "quarter",
+ "month" => "month",
+ "weekofyear" => "week",
+ "weekday" => "day of the week",
+ "day" => "day",
+ "dayperiod" => "AM/PM",
+ "hour" => "hour",
+ "minute" => "minute",
+ "second" => "second",
+ "timezonename" => "time zone",
+ _ => null
+ };
+
+ return name ?? GetFallbackValue(code);
+ }
+
+ private string? GetFallbackValue(string code)
+ {
+ return string.Equals(Fallback, "code", StringComparison.Ordinal) ? code : null;
+ }
+}
diff --git a/Jint/Native/Intl/JsDurationFormat.cs b/Jint/Native/Intl/JsDurationFormat.cs
new file mode 100644
index 0000000000..f2d8769c68
--- /dev/null
+++ b/Jint/Native/Intl/JsDurationFormat.cs
@@ -0,0 +1,1248 @@
+using System.Globalization;
+using System.Text;
+using Jint.Native.Object;
+
+namespace Jint.Native.Intl;
+
+///
+/// https://tc39.es/proposal-intl-duration-format/
+/// Represents an Intl.DurationFormat instance for locale-aware duration formatting.
+///
+internal sealed class JsDurationFormat : ObjectInstance
+{
+ internal JsDurationFormat(
+ Engine engine,
+ ObjectInstance prototype,
+ string locale,
+ string style,
+ string numberingSystem,
+ CultureInfo cultureInfo,
+ string yearsStyle,
+ string monthsStyle,
+ string weeksStyle,
+ string daysStyle,
+ string hoursStyle,
+ string minutesStyle,
+ string secondsStyle,
+ string millisecondsStyle,
+ string microsecondsStyle,
+ string nanosecondsStyle,
+ string yearsDisplay,
+ string monthsDisplay,
+ string weeksDisplay,
+ string daysDisplay,
+ string hoursDisplay,
+ string minutesDisplay,
+ string secondsDisplay,
+ string millisecondsDisplay,
+ string microsecondsDisplay,
+ string nanosecondsDisplay,
+ int? fractionalDigits) : base(engine)
+ {
+ _prototype = prototype;
+ Locale = locale;
+ Style = style;
+ NumberingSystem = numberingSystem;
+ CultureInfo = cultureInfo;
+
+ YearsStyle = yearsStyle;
+ MonthsStyle = monthsStyle;
+ WeeksStyle = weeksStyle;
+ DaysStyle = daysStyle;
+ HoursStyle = hoursStyle;
+ MinutesStyle = minutesStyle;
+ SecondsStyle = secondsStyle;
+ MillisecondsStyle = millisecondsStyle;
+ MicrosecondsStyle = microsecondsStyle;
+ NanosecondsStyle = nanosecondsStyle;
+
+ YearsDisplay = yearsDisplay;
+ MonthsDisplay = monthsDisplay;
+ WeeksDisplay = weeksDisplay;
+ DaysDisplay = daysDisplay;
+ HoursDisplay = hoursDisplay;
+ MinutesDisplay = minutesDisplay;
+ SecondsDisplay = secondsDisplay;
+ MillisecondsDisplay = millisecondsDisplay;
+ MicrosecondsDisplay = microsecondsDisplay;
+ NanosecondsDisplay = nanosecondsDisplay;
+
+ FractionalDigits = fractionalDigits;
+ }
+
+ ///
+ /// Gets the CLDR provider from engine options.
+ ///
+ private ICldrProvider CldrProvider => _engine.Options.Intl.CldrProvider;
+
+ ///
+ /// The locale used for formatting.
+ ///
+ internal string Locale { get; }
+
+ ///
+ /// The style: "long", "short", "narrow", or "digital".
+ ///
+ internal string Style { get; }
+
+ ///
+ /// The numbering system.
+ ///
+ internal string NumberingSystem { get; }
+
+ ///
+ /// The .NET CultureInfo for locale-specific formatting.
+ ///
+ internal CultureInfo CultureInfo { get; }
+
+ // Unit styles
+ internal string YearsStyle { get; }
+ internal string MonthsStyle { get; }
+ internal string WeeksStyle { get; }
+ internal string DaysStyle { get; }
+ internal string HoursStyle { get; }
+ internal string MinutesStyle { get; }
+ internal string SecondsStyle { get; }
+ internal string MillisecondsStyle { get; }
+ internal string MicrosecondsStyle { get; }
+ internal string NanosecondsStyle { get; }
+
+ // Unit displays
+ internal string YearsDisplay { get; }
+ internal string MonthsDisplay { get; }
+ internal string WeeksDisplay { get; }
+ internal string DaysDisplay { get; }
+ internal string HoursDisplay { get; }
+ internal string MinutesDisplay { get; }
+ internal string SecondsDisplay { get; }
+ internal string MillisecondsDisplay { get; }
+ internal string MicrosecondsDisplay { get; }
+ internal string NanosecondsDisplay { get; }
+
+ // Fractional digits for sub-second units
+ internal int? FractionalDigits { get; }
+
+ ///
+ /// Formats a duration object.
+ ///
+ internal string Format(DurationRecord duration)
+ {
+ var isDigital = string.Equals(Style, "digital", StringComparison.Ordinal);
+
+ if (isDigital)
+ {
+ return FormatDigital(duration);
+ }
+
+ return FormatNonDigital(duration);
+ }
+
+ private string FormatDigital(DurationRecord duration)
+ {
+ var parts = new List();
+
+ // Check if the duration is negative
+ var isNegative = duration.Years < 0 || duration.Months < 0 || duration.Weeks < 0 ||
+ duration.Days < 0 || duration.Hours < 0 || duration.Minutes < 0 ||
+ duration.Seconds < 0 || duration.Milliseconds < 0 ||
+ duration.Microseconds < 0 || duration.Nanoseconds < 0;
+ var displayNegativeSign = isNegative;
+
+ // For digital style, years/months/weeks/days are formatted with unit labels using "short" style
+ // Per Table 1 in spec, digital style uses "short" for date units
+ // Date units use grouping (thousand separators) and proper pluralization
+ void AddDateUnitIfNeeded(double value, string display, string unitName)
+ {
+ if (value == 0 && !string.Equals(display, "always", StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ var absValue = System.Math.Abs(value);
+ var prefix = displayNegativeSign ? "-" : "";
+ displayNegativeSign = false;
+
+ // Get unit patterns from CLDR provider for proper pluralization
+ var unitPatterns = CldrProvider.GetUnitPatterns(Locale, unitName, "short");
+ string formatted;
+
+ if (unitPatterns != null)
+ {
+ // Choose singular or plural based on value
+ var isPlural = absValue != 1;
+ var pattern = isPlural ? unitPatterns.Other : (unitPatterns.One ?? unitPatterns.Other);
+
+ // Format the number with grouping using locale's culture
+ var numberWithGrouping = ((long) absValue).ToString("N0", CultureInfo);
+ formatted = pattern.Replace("{0}", $"{prefix}{numberWithGrouping}");
+ }
+ else
+ {
+ // Fallback: use simple formatting with grouping
+ var label = GetUnitLabel(absValue, unitName, "short");
+ var numberWithGrouping = ((long) absValue).ToString("N0", CultureInfo);
+ formatted = $"{prefix}{numberWithGrouping} {label}";
+ }
+
+ parts.Add(formatted);
+ }
+
+ // Add date units with their unit names (per Table 1, digital style uses "short")
+ AddDateUnitIfNeeded(duration.Years, YearsDisplay, "year");
+ AddDateUnitIfNeeded(duration.Months, MonthsDisplay, "month");
+ AddDateUnitIfNeeded(duration.Weeks, WeeksDisplay, "week");
+ AddDateUnitIfNeeded(duration.Days, DaysDisplay, "day");
+
+ // Now format the digital time part (HH:MM:SS.fff)
+ var sb = new StringBuilder();
+
+ var hours = System.Math.Abs(duration.Hours);
+ var minutes = System.Math.Abs(duration.Minutes);
+ var seconds = System.Math.Abs(duration.Seconds);
+ var milliseconds = System.Math.Abs(duration.Milliseconds);
+ var microseconds = System.Math.Abs(duration.Microseconds);
+ var nanoseconds = System.Math.Abs(duration.Nanoseconds);
+
+ // Convert sub-second units to total seconds (milliseconds add to seconds, not just fractional)
+ // Per spec: sub-second units are converted to fractional seconds
+ var totalSubSeconds = milliseconds / 1000.0 + microseconds / 1_000_000.0 + nanoseconds / 1_000_000_000.0;
+ var totalSeconds = seconds + totalSubSeconds;
+ var wholeSeconds = (long) System.Math.Floor(totalSeconds);
+ var fractionalPart = totalSeconds - wholeSeconds;
+
+ // Add negative sign if this is the first displayed element
+ if (displayNegativeSign)
+ {
+ sb.Append('-');
+ displayNegativeSign = false;
+ }
+
+ // Add hours if non-zero or if hoursDisplay is "always"
+ var showHours = duration.Hours != 0 || string.Equals(HoursDisplay, "always", StringComparison.Ordinal);
+
+ if (showHours)
+ {
+ sb.Append((long) hours);
+ sb.Append(':');
+ }
+
+ // Minutes always 2-digit in digital style
+ sb.Append(((long) minutes).ToString("D2", CultureInfo.InvariantCulture));
+
+ sb.Append(':');
+ // Seconds: 2-digit only if less than 100, otherwise use actual digits (no grouping)
+ if (wholeSeconds < 100)
+ {
+ sb.Append(wholeSeconds.ToString("D2", CultureInfo.InvariantCulture));
+ }
+ else
+ {
+ sb.Append(wholeSeconds);
+ }
+
+ // Add fractional seconds if needed
+ if (fractionalPart > 0 || FractionalDigits.HasValue)
+ {
+ if (FractionalDigits.HasValue)
+ {
+ var digits = FractionalDigits.Value;
+ if (digits > 0)
+ {
+ // Format fractional part with specified number of digits
+ var formatStr = "F" + digits;
+ var fractionalStr = fractionalPart.ToString(formatStr, CultureInfo.InvariantCulture);
+ // Remove leading "0" to get just ".xxx"
+#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER
+ sb.Append(fractionalStr.AsSpan(1));
+#else
+ sb.Append(fractionalStr, 1, fractionalStr.Length - 1);
+#endif
+ }
+ }
+ else if (fractionalPart > 0)
+ {
+ // Format fractional part, trimming trailing zeros
+ // Convert to string with enough precision, then trim
+ var fractionalStr = fractionalPart.ToString("F9", CultureInfo.InvariantCulture);
+ // Remove leading "0" and trailing zeros (e.g., "0.567000000" -> ".567")
+ var startIndex = 1; // Skip the leading "0"
+ var endIndex = fractionalStr.Length;
+ while (endIndex > startIndex && fractionalStr[endIndex - 1] == '0')
+ {
+ endIndex--;
+ }
+ if (endIndex > startIndex + 1) // More than just "."
+ {
+#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER
+ sb.Append(fractionalStr.AsSpan(startIndex, endIndex - startIndex));
+#else
+ sb.Append(fractionalStr, startIndex, endIndex - startIndex);
+#endif
+ }
+ }
+ }
+
+ parts.Add(sb.ToString());
+
+ return string.Join(", ", parts);
+ }
+
+ private string FormatNonDigital(DurationRecord duration)
+ {
+ var parts = new List();
+
+ // Check if the duration is negative (any non-zero component is negative)
+ var isNegative = duration.Years < 0 || duration.Months < 0 || duration.Weeks < 0 ||
+ duration.Days < 0 || duration.Hours < 0 || duration.Minutes < 0 ||
+ duration.Seconds < 0 || duration.Milliseconds < 0 ||
+ duration.Microseconds < 0 || duration.Nanoseconds < 0;
+
+ // Track whether we've shown the negative sign yet
+ var displayNegativeSign = isNegative;
+
+ // Helper to check if a style is numeric
+ bool IsNumericStyle(string style) =>
+ string.Equals(style, "numeric", StringComparison.Ordinal) ||
+ string.Equals(style, "2-digit", StringComparison.Ordinal);
+
+ void AddUnitIfNeeded(double value, string display, string unitName, string unitStyle)
+ {
+ if (!ShouldShowUnit(value, display))
+ {
+ return;
+ }
+
+ // Use absolute value for the number, handle sign separately
+ var absValue = System.Math.Abs(value);
+
+ // Format using CLDR provider
+ string formatted;
+ if (displayNegativeSign)
+ {
+ displayNegativeSign = false;
+ var prefix = value == 0 ? "-" : (value < 0 ? "-" : "");
+ formatted = FormatUnitWithCldr(absValue, unitName, unitStyle, prefix);
+ }
+ else
+ {
+ formatted = FormatUnitWithCldr(absValue, unitName, unitStyle, "");
+ }
+
+ parts.Add(formatted);
+ }
+
+ string FormatUnitWithCldr(double absValue, string unitName, string unitStyle, string prefix)
+ {
+ // Get unit patterns from CLDR provider
+ var unitPatterns = CldrProvider.GetUnitPatterns(Locale, unitName, unitStyle);
+ if (unitPatterns == null)
+ {
+ // Fallback using GetUnitLabel
+ var label = GetUnitLabel(absValue, unitName, unitStyle);
+ var needsSpace = !string.Equals(unitStyle, "narrow", StringComparison.Ordinal);
+ return needsSpace ? $"{prefix}{(long) absValue} {label}" : $"{prefix}{(long) absValue}{label}";
+ }
+
+ // Choose singular or plural based on value
+ var isPlural = absValue != 1;
+ var pattern = isPlural ? unitPatterns.Other : (unitPatterns.One ?? unitPatterns.Other);
+
+ // Replace {0} with the formatted number
+ return pattern.Replace("{0}", $"{prefix}{(long) absValue}");
+ }
+
+ void AddSubSecondUnitIfNeeded(double value, string display, string unitName, string unitStyle)
+ {
+ if (!ShouldShowUnit(value, display))
+ {
+ return;
+ }
+
+ // Sub-second units use the same CLDR provider logic as regular units
+ var absValue = System.Math.Abs(value);
+
+ string formatted;
+ if (displayNegativeSign)
+ {
+ displayNegativeSign = false;
+ var prefix = value == 0 ? "-" : (value < 0 ? "-" : "");
+ formatted = FormatUnitWithCldr(absValue, unitName, unitStyle, prefix);
+ }
+ else
+ {
+ formatted = FormatUnitWithCldr(absValue, unitName, unitStyle, "");
+ }
+
+ parts.Add(formatted);
+ }
+
+ // Years
+ AddUnitIfNeeded(duration.Years, YearsDisplay, "year", YearsStyle);
+
+ // Months
+ AddUnitIfNeeded(duration.Months, MonthsDisplay, "month", MonthsStyle);
+
+ // Weeks
+ AddUnitIfNeeded(duration.Weeks, WeeksDisplay, "week", WeeksStyle);
+
+ // Days
+ AddUnitIfNeeded(duration.Days, DaysDisplay, "day", DaysStyle);
+
+ // Check if we need to format time units numerically (with : separators)
+ var hoursIsNumeric = IsNumericStyle(HoursStyle);
+ var minutesIsNumeric = IsNumericStyle(MinutesStyle);
+ var secondsIsNumeric = IsNumericStyle(SecondsStyle);
+
+ // Check if there are any non-zero sub-second units
+ var hasSubSeconds = ShouldShowUnit(duration.Milliseconds, MillisecondsDisplay) ||
+ ShouldShowUnit(duration.Microseconds, MicrosecondsDisplay) ||
+ ShouldShowUnit(duration.Nanoseconds, NanosecondsDisplay);
+
+ // Format hours with unit labels if not numeric
+ if (!hoursIsNumeric)
+ {
+ AddUnitIfNeeded(duration.Hours, HoursDisplay, "hour", HoursStyle);
+ }
+
+ // Determine if we should format minutes/seconds numerically
+ // Numeric mode starts when hours is numeric, or when minutes is numeric
+ var formatTimeNumerically = hoursIsNumeric || minutesIsNumeric;
+
+ // Track whether sub-seconds were consumed as fractional seconds
+ var subSecondsConsumed = false;
+
+ if (formatTimeNumerically)
+ {
+ // Format time units with : separators
+ var sb = new StringBuilder();
+
+ // Add negative sign if needed (only if hours wasn't already shown)
+ if (displayNegativeSign && hoursIsNumeric)
+ {
+ sb.Append('-');
+ displayNegativeSign = false;
+ }
+
+ var hours = System.Math.Abs(duration.Hours);
+ var minutes = System.Math.Abs(duration.Minutes);
+ var seconds = System.Math.Abs(duration.Seconds);
+ var milliseconds = System.Math.Abs(duration.Milliseconds);
+ var microseconds = System.Math.Abs(duration.Microseconds);
+ var nanoseconds = System.Math.Abs(duration.Nanoseconds);
+
+ // Determine which time units should be shown
+ // Note: numeric style only affects HOW units are formatted (with :), not WHETHER they are shown
+ // Cascade rule: later units pull in earlier numeric units ONLY if the earlier unit is shown
+ var shouldShowHoursBase = ShouldShowUnit(duration.Hours, HoursDisplay);
+
+ // When hours is shown and there are sub-seconds, cascade to show minutes and seconds
+ // to format sub-seconds as fractional seconds (e.g., "1:00:00.001")
+ var showSecondsBase = ShouldShowUnit(duration.Seconds, SecondsDisplay);
+ var showSeconds = showSecondsBase || (shouldShowHoursBase && hasSubSeconds);
+
+ // Minutes cascades from hours (to maintain h:mm:ss format) only if hours is also shown
+ var showMinutesBase = ShouldShowUnit(duration.Minutes, MinutesDisplay);
+ var showMinutes = showMinutesBase ||
+ (minutesIsNumeric && showSeconds && shouldShowHoursBase);
+ var showHours = hoursIsNumeric && shouldShowHoursBase;
+
+ if (showHours)
+ {
+ if (string.Equals(HoursStyle, "2-digit", StringComparison.Ordinal))
+ {
+ sb.Append(((long) hours).ToString("D2", CultureInfo.InvariantCulture));
+ }
+ else
+ {
+ sb.Append((long) hours);
+ }
+
+ if (showMinutes || showSeconds)
+ {
+ sb.Append(':');
+ }
+ }
+
+ if (showMinutes)
+ {
+ // Add negative sign before minutes if this is the first numeric unit
+ if (displayNegativeSign && !showHours)
+ {
+ sb.Append('-');
+ displayNegativeSign = false;
+ }
+
+ // Minutes are 2-digit when following hours or when minutes style is 2-digit
+ if (showHours || string.Equals(MinutesStyle, "2-digit", StringComparison.Ordinal))
+ {
+ sb.Append(((long) minutes).ToString("D2", CultureInfo.InvariantCulture));
+ }
+ else
+ {
+ sb.Append((long) minutes);
+ }
+
+ if (showSeconds)
+ {
+ sb.Append(':');
+ }
+ }
+
+ if (showSeconds)
+ {
+ // Seconds are 2-digit when following minutes or when seconds style is 2-digit
+ if (showMinutes || string.Equals(SecondsStyle, "2-digit", StringComparison.Ordinal))
+ {
+ sb.Append(((long) seconds).ToString("D2", CultureInfo.InvariantCulture));
+ }
+ else
+ {
+ sb.Append((long) seconds);
+ }
+
+ // If in numeric format with hours showing, add sub-seconds as fractional part
+ if (showHours && hasSubSeconds)
+ {
+ var totalSubSeconds = milliseconds / 1000.0 + microseconds / 1_000_000.0 + nanoseconds / 1_000_000_000.0;
+ if (totalSubSeconds > 0 || FractionalDigits.HasValue)
+ {
+ if (FractionalDigits.HasValue)
+ {
+ var digits = FractionalDigits.Value;
+ if (digits > 0)
+ {
+ var formatStr = "F" + digits;
+ var fractionalStr = totalSubSeconds.ToString(formatStr, CultureInfo.InvariantCulture);
+ // Remove leading "0" to get just ".xxx"
+#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER
+ sb.Append(fractionalStr.AsSpan(1));
+#else
+ sb.Append(fractionalStr, 1, fractionalStr.Length - 1);
+#endif
+ }
+ }
+ else if (totalSubSeconds > 0)
+ {
+ // Format fractional part, trimming trailing zeros
+ var fractionalStr = totalSubSeconds.ToString("F9", CultureInfo.InvariantCulture);
+ // Remove leading "0" and trailing zeros
+ var startIndex = 1; // Skip the leading "0"
+ var endIndex = fractionalStr.Length;
+ while (endIndex > startIndex && fractionalStr[endIndex - 1] == '0')
+ {
+ endIndex--;
+ }
+ if (endIndex > startIndex + 1) // More than just "."
+ {
+#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER
+ sb.Append(fractionalStr.AsSpan(startIndex, endIndex - startIndex));
+#else
+ sb.Append(fractionalStr, startIndex, endIndex - startIndex);
+#endif
+ }
+ }
+ subSecondsConsumed = true;
+ }
+ }
+ }
+
+ if (sb.Length > 0 && (sb.Length > 1 || sb[0] != '-'))
+ {
+ parts.Add(sb.ToString());
+ }
+ }
+ else
+ {
+ // Format minutes with unit labels (hours already handled above)
+ AddUnitIfNeeded(duration.Minutes, MinutesDisplay, "minute", MinutesStyle);
+
+ // Special case: when seconds is numeric but hours/minutes are not,
+ // and there are no hours/minutes to display, format seconds as just a number
+ if (secondsIsNumeric && !ShouldShowUnit(duration.Hours, HoursDisplay) && !ShouldShowUnit(duration.Minutes, MinutesDisplay))
+ {
+ // Format seconds (including sub-seconds) as a plain numeric value
+ var seconds = System.Math.Abs(duration.Seconds);
+ var milliseconds = System.Math.Abs(duration.Milliseconds);
+ var microseconds = System.Math.Abs(duration.Microseconds);
+ var nanoseconds = System.Math.Abs(duration.Nanoseconds);
+
+ // Convert sub-seconds to total seconds
+ var totalSubSeconds = milliseconds / 1000.0 + microseconds / 1_000_000.0 + nanoseconds / 1_000_000_000.0;
+ var totalSeconds = seconds + totalSubSeconds;
+
+ if (ShouldShowUnit(totalSeconds, SecondsDisplay) || totalSubSeconds > 0)
+ {
+ var prefix = displayNegativeSign ? "-" : "";
+ if (displayNegativeSign)
+ {
+ displayNegativeSign = false;
+ }
+
+ // Format with truncation rounding per spec
+ string formatted;
+ if (FractionalDigits.HasValue)
+ {
+ var digits = FractionalDigits.Value;
+ // Truncate to specified digits
+ var multiplier = System.Math.Pow(10, digits);
+ var truncated = System.Math.Truncate(totalSeconds * multiplier) / multiplier;
+ if (digits > 0)
+ {
+ formatted = truncated.ToString($"F{digits}", CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ formatted = ((long) truncated).ToString(CultureInfo.InvariantCulture);
+ }
+ }
+ else
+ {
+ // No fractional digits specified - use default formatting with trailing zeros trimmed
+ formatted = totalSeconds.ToString("0.#########", CultureInfo.InvariantCulture);
+ }
+
+ parts.Add($"{prefix}{formatted}");
+ subSecondsConsumed = true;
+ }
+ }
+ else
+ {
+ // Format seconds with unit label
+ AddUnitIfNeeded(duration.Seconds, SecondsDisplay, "second", SecondsStyle);
+ }
+ }
+
+ // Only add sub-seconds with labels if they weren't consumed as fractional seconds
+ if (!subSecondsConsumed)
+ {
+ // Check for fractional cascade: when a sub-second unit has "numeric" style,
+ // the previous unit should include it as a fractional part
+ var microsecondsIsNumeric = IsNumericStyle(MicrosecondsStyle);
+ var nanosecondsIsNumeric = IsNumericStyle(NanosecondsStyle);
+
+ // Helper to format a sub-second unit with fractional parts from subsequent units
+ void AddSubSecondWithFractional(double value, string display, string unitName, string unitStyle,
+ double fractionalValue, int fractionalDigits)
+ {
+ if (!ShouldShowUnit(value, display) && fractionalValue == 0)
+ {
+ return;
+ }
+
+ var absValue = System.Math.Abs(value);
+ var totalValue = absValue + fractionalValue;
+
+ // Get unit patterns from CLDR provider
+ var unitPatterns = CldrProvider.GetUnitPatterns(Locale, unitName, unitStyle);
+ string formatted;
+
+ if (unitPatterns != null)
+ {
+ // Choose singular or plural based on whole value (not fractional)
+ var isPlural = absValue != 1;
+ var pattern = isPlural ? unitPatterns.Other : (unitPatterns.One ?? unitPatterns.Other);
+
+ // Format the number with fractional digits
+ string numStr;
+ if (fractionalValue > 0)
+ {
+ // Format with up to 9 decimal places, trimming trailing zeros
+ numStr = totalValue.ToString("0.#########", CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ numStr = ((long) absValue).ToString(CultureInfo.InvariantCulture);
+ }
+
+ var prefix = displayNegativeSign ? "-" : "";
+ if (displayNegativeSign)
+ {
+ displayNegativeSign = false;
+ }
+
+ formatted = pattern.Replace("{0}", $"{prefix}{numStr}");
+ }
+ else
+ {
+ // Fallback
+ var label = GetUnitLabel(absValue, unitName, unitStyle);
+ var needsSpace = !string.Equals(unitStyle, "narrow", StringComparison.Ordinal);
+ string numStr;
+ if (fractionalValue > 0)
+ {
+ numStr = totalValue.ToString("0.#########", CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ numStr = ((long) absValue).ToString(CultureInfo.InvariantCulture);
+ }
+
+ var prefix = displayNegativeSign ? "-" : "";
+ if (displayNegativeSign)
+ {
+ displayNegativeSign = false;
+ }
+
+ formatted = needsSpace ? $"{prefix}{numStr} {label}" : $"{prefix}{numStr}{label}";
+ }
+
+ parts.Add(formatted);
+ }
+
+ var milliseconds = System.Math.Abs(duration.Milliseconds);
+ var microseconds = System.Math.Abs(duration.Microseconds);
+ var nanoseconds = System.Math.Abs(duration.Nanoseconds);
+
+ // Case 1: microseconds is numeric - milliseconds gets fractional part from micro+nano
+ if (microsecondsIsNumeric && !IsNumericStyle(MillisecondsStyle))
+ {
+ // Milliseconds includes microseconds and nanoseconds as fractional part
+ // e.g., 444 ms + 55 μs + 6 ns = 444.055006 ms
+ var fractionalPart = microseconds / 1000.0 + nanoseconds / 1_000_000.0;
+ AddSubSecondWithFractional(duration.Milliseconds, MillisecondsDisplay, "millisecond", MillisecondsStyle,
+ fractionalPart, 9);
+ // Skip microseconds and nanoseconds - they're consumed
+ }
+ // Case 2: nanoseconds is numeric but microseconds is not - microseconds gets fractional from nano
+ else if (nanosecondsIsNumeric && !microsecondsIsNumeric && !IsNumericStyle(MillisecondsStyle))
+ {
+ // Milliseconds formatted normally
+ AddSubSecondUnitIfNeeded(duration.Milliseconds, MillisecondsDisplay, "millisecond", MillisecondsStyle);
+
+ // Microseconds includes nanoseconds as fractional part
+ // e.g., 55 μs + 6 ns = 55.006 μs
+ var fractionalPart = nanoseconds / 1000.0;
+ AddSubSecondWithFractional(duration.Microseconds, MicrosecondsDisplay, "microsecond", MicrosecondsStyle,
+ fractionalPart, 9);
+ // Skip nanoseconds - they're consumed
+ }
+ // Case 3: No fractional cascade - format all sub-seconds normally
+ else
+ {
+ // Milliseconds
+ AddSubSecondUnitIfNeeded(duration.Milliseconds, MillisecondsDisplay, "millisecond", MillisecondsStyle);
+
+ // Microseconds
+ AddSubSecondUnitIfNeeded(duration.Microseconds, MicrosecondsDisplay, "microsecond", MicrosecondsStyle);
+
+ // Nanoseconds
+ AddSubSecondUnitIfNeeded(duration.Nanoseconds, NanosecondsDisplay, "nanosecond", NanosecondsStyle);
+ }
+ }
+
+ if (parts.Count == 0)
+ {
+ // If all units are zero and their display is "auto", return empty string
+ // Only show "0 seconds" if at least one unit has display "always"
+ if (HasAnyAlwaysDisplay())
+ {
+ // Format zero seconds using CLDR provider
+ var unitPatterns = CldrProvider.GetUnitPatterns(Locale, "second", SecondsStyle);
+ if (unitPatterns != null)
+ {
+ // Use plural form for 0 (CLDR treats 0 as "other" category)
+ return unitPatterns.Other.Replace("{0}", "0");
+ }
+ else
+ {
+ var label = GetUnitLabel(0, "second", SecondsStyle);
+ var needsSpace = !string.Equals(SecondsStyle, "narrow", StringComparison.Ordinal);
+ return needsSpace ? $"0 {label}" : $"0{label}";
+ }
+ }
+ return "";
+ }
+
+ // Join parts based on style
+ if (string.Equals(Style, "narrow", StringComparison.Ordinal))
+ {
+ return string.Join(" ", parts);
+ }
+
+ // For long and short, use comma-separated
+ // Note: The test harness expects comma separation, not "and" conjunction
+ return string.Join(", ", parts);
+ }
+
+ private static bool ShouldShowUnit(double value, string display)
+ {
+ if (string.Equals(display, "always", StringComparison.Ordinal))
+ {
+ return true;
+ }
+ // "auto" - only show if non-zero
+ return value != 0;
+ }
+
+ private bool HasAnyAlwaysDisplay()
+ {
+ return string.Equals(YearsDisplay, "always", StringComparison.Ordinal) ||
+ string.Equals(MonthsDisplay, "always", StringComparison.Ordinal) ||
+ string.Equals(WeeksDisplay, "always", StringComparison.Ordinal) ||
+ string.Equals(DaysDisplay, "always", StringComparison.Ordinal) ||
+ string.Equals(HoursDisplay, "always", StringComparison.Ordinal) ||
+ string.Equals(MinutesDisplay, "always", StringComparison.Ordinal) ||
+ string.Equals(SecondsDisplay, "always", StringComparison.Ordinal) ||
+ string.Equals(MillisecondsDisplay, "always", StringComparison.Ordinal) ||
+ string.Equals(MicrosecondsDisplay, "always", StringComparison.Ordinal) ||
+ string.Equals(NanosecondsDisplay, "always", StringComparison.Ordinal);
+ }
+
+ private static string FormatUnit(double value, string singularLong, string pluralLong, string shortForm, string narrowForm, string unitStyle)
+ {
+ var isPlural = System.Math.Abs(value) != 1;
+
+ // Handle numeric and 2-digit styles
+ if (string.Equals(unitStyle, "numeric", StringComparison.Ordinal))
+ {
+ return ((long) value).ToString(CultureInfo.InvariantCulture);
+ }
+
+ if (string.Equals(unitStyle, "2-digit", StringComparison.Ordinal))
+ {
+ return ((long) value).ToString("D2", CultureInfo.InvariantCulture);
+ }
+
+ return unitStyle switch
+ {
+ "long" => $"{(long) value} {(isPlural ? pluralLong : singularLong)}",
+ "short" => $"{(long) value} {shortForm}",
+ "narrow" => $"{(long) value}{narrowForm}",
+ _ => $"{(long) value} {shortForm}" // Default to short
+ };
+ }
+
+ private static string FormatUnitWithSign(double absValue, string singularLong, string pluralLong, string shortForm, string narrowForm, string unitStyle, bool isNegative = false, bool isNegativeZero = false)
+ {
+ var isPlural = absValue != 1;
+ var prefix = (isNegative || isNegativeZero) ? "-" : "";
+
+ // Handle numeric and 2-digit styles
+ if (string.Equals(unitStyle, "numeric", StringComparison.Ordinal))
+ {
+ return $"{prefix}{(long) absValue}";
+ }
+
+ if (string.Equals(unitStyle, "2-digit", StringComparison.Ordinal))
+ {
+ return $"{prefix}{(long) absValue:D2}";
+ }
+
+ return unitStyle switch
+ {
+ "long" => $"{prefix}{(long) absValue} {(isPlural ? pluralLong : singularLong)}",
+ "short" => $"{prefix}{(long) absValue} {shortForm}",
+ "narrow" => $"{prefix}{(long) absValue}{narrowForm}",
+ _ => $"{prefix}{(long) absValue} {shortForm}" // Default to short
+ };
+ }
+
+ ///
+ /// Formats sub-second units (milliseconds, microseconds, nanoseconds).
+ /// CLDR uses singular form for short/narrow and proper plural for long.
+ ///
+ private static string FormatSubSecondUnit(double value, string singular, string plural, string unitStyle)
+ {
+ var isPlural = System.Math.Abs(value) != 1;
+
+ // Handle numeric and 2-digit styles
+ if (string.Equals(unitStyle, "numeric", StringComparison.Ordinal))
+ {
+ return ((long) value).ToString(CultureInfo.InvariantCulture);
+ }
+
+ if (string.Equals(unitStyle, "2-digit", StringComparison.Ordinal))
+ {
+ return ((long) value).ToString("D2", CultureInfo.InvariantCulture);
+ }
+
+ // For sub-second units:
+ // - long uses proper plural
+ // - short/narrow use singular form always
+ return unitStyle switch
+ {
+ "long" => $"{(long) value} {(isPlural ? plural : singular)}",
+ "short" => $"{(long) value} {singular}",
+ "narrow" => $"{(long) value}{singular}", // narrow has no space
+ _ => $"{(long) value} {singular}" // Default to short (singular)
+ };
+ }
+
+ private static string FormatSubSecondUnitWithSign(double absValue, string singular, string plural, string unitStyle, bool isNegative = false, bool isNegativeZero = false)
+ {
+ var isPlural = absValue != 1;
+ var prefix = (isNegative || isNegativeZero) ? "-" : "";
+
+ // Handle numeric and 2-digit styles
+ if (string.Equals(unitStyle, "numeric", StringComparison.Ordinal))
+ {
+ return $"{prefix}{(long) absValue}";
+ }
+
+ if (string.Equals(unitStyle, "2-digit", StringComparison.Ordinal))
+ {
+ return $"{prefix}{(long) absValue:D2}";
+ }
+
+ // For sub-second units:
+ // - long uses proper plural
+ // - short/narrow use singular form always
+ return unitStyle switch
+ {
+ "long" => $"{prefix}{(long) absValue} {(isPlural ? plural : singular)}",
+ "short" => $"{prefix}{(long) absValue} {singular}",
+ "narrow" => $"{prefix}{(long) absValue}{singular}", // narrow has no space
+ _ => $"{prefix}{(long) absValue} {singular}" // Default to short (singular)
+ };
+ }
+
+ ///
+ /// Formats a duration object and returns parts.
+ ///
+ internal JsArray FormatToParts(Engine engine, DurationRecord duration)
+ {
+ var isDigital = string.Equals(Style, "digital", StringComparison.Ordinal);
+
+ if (isDigital)
+ {
+ return FormatToPartsDigital(engine, duration);
+ }
+
+ return FormatToPartsNonDigital(engine, duration);
+ }
+
+ private JsArray FormatToPartsDigital(Engine engine, DurationRecord duration)
+ {
+ var result = new JsArray(engine);
+ uint index = 0;
+
+ // Check if the duration is negative
+ var isNegative = duration.Years < 0 || duration.Months < 0 || duration.Weeks < 0 ||
+ duration.Days < 0 || duration.Hours < 0 || duration.Minutes < 0 ||
+ duration.Seconds < 0 || duration.Milliseconds < 0 ||
+ duration.Microseconds < 0 || duration.Nanoseconds < 0;
+ var displayNegativeSign = isNegative;
+
+ // Use absolute values for formatting
+ var years = System.Math.Abs(duration.Years);
+ var months = System.Math.Abs(duration.Months);
+ var weeks = System.Math.Abs(duration.Weeks);
+ var days = System.Math.Abs(duration.Days);
+ var hours = System.Math.Abs(duration.Hours);
+ var minutes = System.Math.Abs(duration.Minutes);
+ var seconds = System.Math.Abs(duration.Seconds);
+ var milliseconds = System.Math.Abs(duration.Milliseconds);
+ var microseconds = System.Math.Abs(duration.Microseconds);
+ var nanoseconds = System.Math.Abs(duration.Nanoseconds);
+
+ // Convert sub-second units to total seconds
+ var totalSubSeconds = milliseconds / 1000.0 + microseconds / 1_000_000.0 + nanoseconds / 1_000_000_000.0;
+ var totalSeconds = seconds + totalSubSeconds;
+ var wholeSeconds = (long) System.Math.Floor(totalSeconds);
+ var fractionalPart = totalSeconds - wholeSeconds;
+
+ void AddPart(string type, string value, string? unit = null)
+ {
+ var part = OrdinaryObjectCreate(engine, engine.Realm.Intrinsics.Object.PrototypeObject);
+ part.Set("type", type);
+ part.Set("value", value);
+ if (unit != null)
+ {
+ part.Set("unit", unit);
+ }
+ result.SetIndexValue(index++, part, updateLength: true);
+ }
+
+ // Helper to add date unit parts (for years, months, weeks, days in digital style)
+ void AddDateUnitParts(double value, string display, string unitName)
+ {
+ if (value == 0 && !string.Equals(display, "always", StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ // Add separator if not first
+ if (index > 0)
+ {
+ AddPart("literal", ", ");
+ }
+
+ // Add minus sign for first displayed unit of negative duration
+ if (displayNegativeSign)
+ {
+ displayNegativeSign = false;
+ AddPart("minusSign", "-", unitName);
+ }
+
+ // Add integer value with grouping
+ AddPart("integer", ((long) value).ToString("N0", CultureInfo), unitName);
+
+ // Add space separator
+ AddPart("literal", " ", unitName);
+
+ // Get proper short form label with pluralization
+ var unitPatterns = CldrProvider.GetUnitPatterns(Locale, unitName, "short");
+ string shortLabel;
+ if (unitPatterns != null)
+ {
+ // Extract just the unit part from the pattern (e.g., "{0} days" -> "days")
+ var pattern = value != 1 ? unitPatterns.Other : (unitPatterns.One ?? unitPatterns.Other);
+ shortLabel = pattern.Replace("{0}", "").Trim();
+ }
+ else
+ {
+ // Fallback using GetUnitLabel
+ shortLabel = GetUnitLabel(value, unitName, "short");
+ }
+
+ // Add unit label (short form for digital style with proper pluralization)
+ AddPart("unit", shortLabel, unitName);
+ }
+
+ // Add date unit parts (digital style uses short form labels with proper pluralization)
+ AddDateUnitParts(years, YearsDisplay, "year");
+ AddDateUnitParts(months, MonthsDisplay, "month");
+ AddDateUnitParts(weeks, WeeksDisplay, "week");
+ AddDateUnitParts(days, DaysDisplay, "day");
+
+ // Add separator before time portion if there were date units
+ if (index > 0)
+ {
+ AddPart("literal", ", ");
+ }
+
+ // Add minus sign for time if negative and no date units were shown
+ if (displayNegativeSign)
+ {
+ displayNegativeSign = false;
+ AddPart("minusSign", "-", "hour");
+ }
+
+ // Hours
+ var showHours = duration.Hours != 0 || string.Equals(HoursDisplay, "always", StringComparison.Ordinal);
+ if (showHours)
+ {
+ AddPart("integer", ((long) hours).ToString(CultureInfo.InvariantCulture), "hour");
+ AddPart("literal", ":");
+ }
+
+ // Minutes
+ AddPart("integer", ((long) minutes).ToString("D2", CultureInfo.InvariantCulture), "minute");
+ AddPart("literal", ":");
+
+ // Seconds (whole part)
+ if (wholeSeconds < 100)
+ {
+ AddPart("integer", wholeSeconds.ToString("D2", CultureInfo.InvariantCulture), "second");
+ }
+ else
+ {
+ AddPart("integer", wholeSeconds.ToString(CultureInfo.InvariantCulture), "second");
+ }
+
+ // Fractional seconds if needed
+ if (fractionalPart > 0 || FractionalDigits.HasValue)
+ {
+ string? fractionStr = null;
+
+ if (FractionalDigits.HasValue)
+ {
+ var digits = FractionalDigits.Value;
+ if (digits > 0)
+ {
+ var formatStr = "F" + digits;
+ var fractionalStr = fractionalPart.ToString(formatStr, CultureInfo.InvariantCulture);
+ // Remove leading "0" to get just the digits after decimal
+ fractionStr = fractionalStr.Substring(2); // Skip "0."
+ }
+ }
+ else if (fractionalPart > 0)
+ {
+ var fractionalStr = fractionalPart.ToString("F9", CultureInfo.InvariantCulture);
+ // Remove leading "0." and trailing zeros
+ var startIndex = 2; // Skip "0."
+ var endIndex = fractionalStr.Length;
+ while (endIndex > startIndex && fractionalStr[endIndex - 1] == '0')
+ {
+ endIndex--;
+ }
+ if (endIndex > startIndex)
+ {
+ fractionStr = fractionalStr.Substring(startIndex, endIndex - startIndex);
+ }
+ }
+
+ if (fractionStr is { Length: > 0 })
+ {
+ AddPart("decimal", ".", "second");
+ AddPart("fraction", fractionStr, "second");
+ }
+ }
+
+ return result;
+ }
+
+ private JsArray FormatToPartsNonDigital(Engine engine, DurationRecord duration)
+ {
+ var result = new JsArray(engine);
+ uint index = 0;
+
+ // Check if the duration is negative
+ var isNegative = duration.Years < 0 || duration.Months < 0 || duration.Weeks < 0 ||
+ duration.Days < 0 || duration.Hours < 0 || duration.Minutes < 0 ||
+ duration.Seconds < 0 || duration.Milliseconds < 0 ||
+ duration.Microseconds < 0 || duration.Nanoseconds < 0;
+ var displayNegativeSign = isNegative;
+
+ void AddPart(string type, string value, string? unit = null)
+ {
+ var part = OrdinaryObjectCreate(engine, engine.Realm.Intrinsics.Object.PrototypeObject);
+ part.Set("type", type);
+ part.Set("value", value);
+ if (unit != null)
+ {
+ part.Set("unit", unit);
+ }
+ result.SetIndexValue(index++, part, updateLength: true);
+ }
+
+ void AddUnitParts(double unitValue, string unitName, string display, string unitStyle)
+ {
+ if (!ShouldShowUnit(unitValue, display))
+ {
+ return;
+ }
+
+ // Add separator if not first
+ if (index > 0)
+ {
+ // Narrow style uses space separator, others use comma-space
+ var separator = string.Equals(Style, "narrow", StringComparison.Ordinal) ? " " : ", ";
+ AddPart("literal", separator);
+ }
+
+ // Use absolute value for formatting
+ var absValue = System.Math.Abs(unitValue);
+
+ // Add minus sign for first displayed unit of negative duration
+ if (displayNegativeSign)
+ {
+ displayNegativeSign = false;
+ AddPart("minusSign", "-", unitName);
+ }
+
+ // Add integer part
+ string valueStr;
+ if (string.Equals(unitStyle, "2-digit", StringComparison.Ordinal))
+ {
+ valueStr = ((long) absValue).ToString("D2", CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ valueStr = ((long) absValue).ToString(CultureInfo.InvariantCulture);
+ }
+
+ AddPart("integer", valueStr, unitName);
+
+ // Add unit label for non-numeric styles
+ if (!string.Equals(unitStyle, "numeric", StringComparison.Ordinal) &&
+ !string.Equals(unitStyle, "2-digit", StringComparison.Ordinal))
+ {
+ // Narrow style: no space between number and unit
+ // Long/short style: space between number and unit
+ if (!string.Equals(unitStyle, "narrow", StringComparison.Ordinal))
+ {
+ AddPart("literal", " ", unitName);
+ }
+ var label = GetUnitLabel(absValue, unitName, unitStyle);
+ AddPart("unit", label, unitName);
+ }
+ }
+
+ // Add parts for each unit
+ AddUnitParts(duration.Years, "year", YearsDisplay, YearsStyle);
+ AddUnitParts(duration.Months, "month", MonthsDisplay, MonthsStyle);
+ AddUnitParts(duration.Weeks, "week", WeeksDisplay, WeeksStyle);
+ AddUnitParts(duration.Days, "day", DaysDisplay, DaysStyle);
+ AddUnitParts(duration.Hours, "hour", HoursDisplay, HoursStyle);
+ AddUnitParts(duration.Minutes, "minute", MinutesDisplay, MinutesStyle);
+ AddUnitParts(duration.Seconds, "second", SecondsDisplay, SecondsStyle);
+ AddUnitParts(duration.Milliseconds, "millisecond", MillisecondsDisplay, MillisecondsStyle);
+ AddUnitParts(duration.Microseconds, "microsecond", MicrosecondsDisplay, MicrosecondsStyle);
+ AddUnitParts(duration.Nanoseconds, "nanosecond", NanosecondsDisplay, NanosecondsStyle);
+
+ // If no parts, add zero seconds
+ if (index == 0)
+ {
+ AddPart("integer", "0", "second");
+ AddPart("literal", " ", "second");
+ AddPart("unit", GetUnitLabel(0, "second", SecondsStyle), "second");
+ }
+
+ return result;
+ }
+
+ private string GetUnitLabel(double value, string unitName, string style)
+ {
+ // Use CLDR provider to get unit patterns
+ // This ensures consistency with NumberFormat and provides correct formatting (periods, spaces, etc.)
+ var unitPatterns = CldrProvider.GetUnitPatterns(Locale, unitName, style);
+ if (unitPatterns != null)
+ {
+ // Choose singular or plural form based on value
+ var isPlural = System.Math.Abs(value) != 1;
+ var pattern = isPlural ? unitPatterns.Other : (unitPatterns.One ?? unitPatterns.Other);
+
+ // Extract the unit suffix from the pattern by removing {0} placeholder
+ // Pattern format is typically "{0} unit" or "{0}unit" (space is part of pattern)
+ var placeholderIndex = pattern.IndexOf("{0}", StringComparison.Ordinal);
+ if (placeholderIndex >= 0)
+ {
+ // Get the part after {0}
+ var afterPlaceholder = pattern.Substring(placeholderIndex + 3);
+
+ // The unit suffix starts after any leading space
+ // We trim here because the space is added separately in the calling code
+ return afterPlaceholder.TrimStart();
+ }
+
+ // If no placeholder found, return pattern as-is (shouldn't happen with valid CLDR data)
+ return pattern;
+ }
+
+ // Fallback to hardcoded values if CLDR provider doesn't have data
+ // This preserves backwards compatibility for non-English locales
+ var plural = System.Math.Abs(value) != 1;
+ var isLong = string.Equals(style, "long", StringComparison.Ordinal);
+
+ if (string.Equals(unitName, "millisecond", StringComparison.Ordinal))
+ {
+ return isLong ? (plural ? "milliseconds" : "millisecond") : "millisecond";
+ }
+ else if (string.Equals(unitName, "microsecond", StringComparison.Ordinal))
+ {
+ return isLong ? (plural ? "microseconds" : "microsecond") : "microsecond";
+ }
+ else if (string.Equals(unitName, "nanosecond", StringComparison.Ordinal))
+ {
+ return isLong ? (plural ? "nanoseconds" : "nanosecond") : "nanosecond";
+ }
+
+ return unitName;
+ }
+
+ [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)]
+ internal struct DurationRecord
+ {
+ public double Years;
+ public double Months;
+ public double Weeks;
+ public double Days;
+ public double Hours;
+ public double Minutes;
+ public double Seconds;
+ public double Milliseconds;
+ public double Microseconds;
+ public double Nanoseconds;
+ }
+}
diff --git a/Jint/Native/Intl/JsListFormat.cs b/Jint/Native/Intl/JsListFormat.cs
new file mode 100644
index 0000000000..c62b638062
--- /dev/null
+++ b/Jint/Native/Intl/JsListFormat.cs
@@ -0,0 +1,231 @@
+using System.Globalization;
+using System.Text;
+using Jint.Native.Object;
+
+namespace Jint.Native.Intl;
+
+///
+/// https://tc39.es/ecma402/#sec-listformat-objects
+/// Represents an Intl.ListFormat instance for locale-aware list formatting.
+///
+internal sealed class JsListFormat : ObjectInstance
+{
+ internal JsListFormat(
+ Engine engine,
+ ObjectInstance prototype,
+ string locale,
+ string type,
+ string style,
+ CultureInfo cultureInfo) : base(engine)
+ {
+ _prototype = prototype;
+ Locale = locale;
+ ListType = type;
+ Style = style;
+ CultureInfo = cultureInfo;
+ }
+
+ ///
+ /// The locale used for formatting.
+ ///
+ internal string Locale { get; }
+
+ ///
+ /// The type of list: "conjunction" (and), "disjunction" (or), or "unit".
+ ///
+ internal string ListType { get; }
+
+ ///
+ /// The style: "long", "short", or "narrow".
+ ///
+ internal string Style { get; }
+
+ ///
+ /// The .NET CultureInfo for locale-specific formatting.
+ ///
+ internal CultureInfo CultureInfo { get; }
+
+ ///
+ /// Gets the CLDR provider from engine options.
+ ///
+ private ICldrProvider CldrProvider => _engine.Options.Intl.CldrProvider;
+
+ ///
+ /// Formats a list of strings according to the locale and options.
+ ///
+ internal string Format(string[] list)
+ {
+ if (list.Length == 0)
+ {
+ return "";
+ }
+
+ if (list.Length == 1)
+ {
+ return list[0];
+ }
+
+ // Get separators based on type and style
+ GetSeparators(out var separator, out var twoItemSeparator, out var finalSeparator);
+
+ if (list.Length == 2)
+ {
+ return $"{list[0]}{twoItemSeparator}{list[1]}";
+ }
+
+ // For 3+ items: "A, B, and C" or "A, B, or C"
+ var result = new ValueStringBuilder();
+ for (var i = 0; i < list.Length; i++)
+ {
+ if (i > 0)
+ {
+ if (i == list.Length - 1)
+ {
+ result.Append(finalSeparator);
+ }
+ else
+ {
+ result.Append(separator);
+ }
+ }
+ result.Append(list[i]);
+ }
+
+ return result.ToString();
+ }
+
+ ///
+ /// Gets the separators based on type and style.
+ /// CLDR list patterns use different templates based on position:
+ /// - start: "{0}, {1}" (first two items)
+ /// - middle: "{0}, {1}" (middle items)
+ /// - end: "{0}, and {1}" (last two items)
+ /// - two: "{0} and {1}" (exactly two items - no comma before "and")
+ ///
+ private void GetSeparators(out string separator, out string twoItemSeparator, out string finalSeparator)
+ {
+ // Try to get patterns from CLDR provider
+ var patterns = CldrProvider.GetListPatterns(Locale, ListType, Style);
+ if (patterns != null)
+ {
+ // Extract separators from CLDR patterns
+ // Patterns are in format "{0}, {1}" or "{0} and {1}"
+ separator = ExtractSeparatorFromPattern(patterns.Middle);
+ twoItemSeparator = ExtractSeparatorFromPattern(patterns.Two);
+ finalSeparator = ExtractSeparatorFromPattern(patterns.End);
+ return;
+ }
+
+ // Fallback to hardcoded patterns
+ var isEnglish = Locale.StartsWith("en", StringComparison.OrdinalIgnoreCase);
+
+ separator = ", ";
+ twoItemSeparator = ", ";
+ finalSeparator = ", ";
+
+ if (string.Equals(ListType, "conjunction", StringComparison.Ordinal))
+ {
+ if (string.Equals(Style, "long", StringComparison.Ordinal))
+ {
+ twoItemSeparator = isEnglish ? " and " : ", ";
+ finalSeparator = isEnglish ? ", and " : ", ";
+ }
+ else if (string.Equals(Style, "short", StringComparison.Ordinal))
+ {
+ twoItemSeparator = isEnglish ? " & " : ", ";
+ finalSeparator = isEnglish ? ", & " : ", ";
+ }
+ // narrow: use default ", " separators
+ }
+ else if (string.Equals(ListType, "disjunction", StringComparison.Ordinal))
+ {
+ // All disjunction styles use " or " / ", or " for English
+ twoItemSeparator = isEnglish ? " or " : ", ";
+ finalSeparator = isEnglish ? ", or " : ", ";
+ }
+ else if (string.Equals(ListType, "unit", StringComparison.Ordinal))
+ {
+ if (string.Equals(Style, "narrow", StringComparison.Ordinal))
+ {
+ separator = " ";
+ twoItemSeparator = " ";
+ finalSeparator = " ";
+ }
+ // short and long: use default ", " separators
+ }
+ }
+
+ ///
+ /// Extracts the separator string from a CLDR pattern like "{0}, {1}" or "{0} and {1}".
+ ///
+ private static string ExtractSeparatorFromPattern(string pattern)
+ {
+ // Pattern format: "{0}SEPARATOR{1}"
+ const string placeholder0 = "{0}";
+ const string placeholder1 = "{1}";
+
+ var idx0 = pattern.IndexOf(placeholder0, StringComparison.Ordinal);
+ var idx1 = pattern.IndexOf(placeholder1, StringComparison.Ordinal);
+
+ if (idx0 >= 0 && idx1 > idx0)
+ {
+ // Extract the text between {0} and {1}
+ var start = idx0 + placeholder0.Length;
+ return pattern.Substring(start, idx1 - start);
+ }
+
+ // Fallback if pattern doesn't match expected format
+ return ", ";
+ }
+
+ ///
+ /// Formats a list and returns an array of parts.
+ ///
+ internal JsArray FormatToParts(Engine engine, string[] list)
+ {
+ var result = new JsArray(engine);
+ uint index = 0;
+
+ if (list.Length == 0)
+ {
+ return result;
+ }
+
+ GetSeparators(out var separator, out var twoItemSeparator, out var finalSeparator);
+
+ // For 2 items, use twoItemSeparator
+ // For 3+ items, use separator for middle, finalSeparator for last
+ var actualSeparator = list.Length == 2 ? twoItemSeparator : finalSeparator;
+
+ for (var i = 0; i < list.Length; i++)
+ {
+ // Add element part
+ var elementPart = ObjectInstance.OrdinaryObjectCreate(engine, engine.Realm.Intrinsics.Object.PrototypeObject);
+ elementPart.Set("type", "element");
+ elementPart.Set("value", list[i]);
+ result.SetIndexValue(index++, elementPart, updateLength: false);
+
+ // Add separator if not last element
+ if (i < list.Length - 1)
+ {
+ var literalPart = ObjectInstance.OrdinaryObjectCreate(engine, engine.Realm.Intrinsics.Object.PrototypeObject);
+ literalPart.Set("type", "literal");
+ // For 2-item lists, use twoItemSeparator; for 3+ items, use separator (middle) or finalSeparator (end)
+ string sep;
+ if (list.Length == 2)
+ {
+ sep = twoItemSeparator;
+ }
+ else
+ {
+ sep = i == list.Length - 2 ? finalSeparator : separator;
+ }
+ literalPart.Set("value", sep);
+ result.SetIndexValue(index++, literalPart, updateLength: false);
+ }
+ }
+
+ result.SetLength(index);
+ return result;
+ }
+}
diff --git a/Jint/Native/Intl/JsRelativeTimeFormat.cs b/Jint/Native/Intl/JsRelativeTimeFormat.cs
new file mode 100644
index 0000000000..73c99b2a08
--- /dev/null
+++ b/Jint/Native/Intl/JsRelativeTimeFormat.cs
@@ -0,0 +1,557 @@
+using System.Globalization;
+using Jint.Native.Object;
+
+namespace Jint.Native.Intl;
+
+///
+/// https://tc39.es/ecma402/#sec-relativetimeformat-objects
+/// Represents an Intl.RelativeTimeFormat instance for locale-aware relative time formatting.
+///
+internal sealed class JsRelativeTimeFormat : ObjectInstance
+{
+ internal JsRelativeTimeFormat(
+ Engine engine,
+ ObjectInstance prototype,
+ string locale,
+ string numberingSystem,
+ string style,
+ string numeric,
+ CultureInfo cultureInfo,
+ JsNumberFormat numberFormat) : base(engine)
+ {
+ _prototype = prototype;
+ Locale = locale;
+ NumberingSystem = numberingSystem;
+ Style = style;
+ Numeric = numeric;
+ CultureInfo = cultureInfo;
+ NumberFormat = numberFormat;
+ }
+
+ ///
+ /// The locale used for formatting.
+ ///
+ internal string Locale { get; }
+
+ ///
+ /// The numbering system used for formatting digits.
+ ///
+ internal string NumberingSystem { get; }
+
+ ///
+ /// The style: "long", "short", or "narrow".
+ ///
+ internal string Style { get; }
+
+ ///
+ /// The numeric: "always" or "auto".
+ ///
+ internal string Numeric { get; }
+
+ ///
+ /// The NumberFormat instance for formatting numbers (per ECMA-402 17.1.1 step 24).
+ ///
+ internal JsNumberFormat NumberFormat { get; }
+
+ ///
+ /// The .NET CultureInfo for locale-specific formatting.
+ ///
+ internal CultureInfo CultureInfo { get; }
+
+ ///
+ /// Gets the CLDR provider from engine options.
+ ///
+ private ICldrProvider CldrProvider => _engine.Options.Intl.CldrProvider;
+
+ ///
+ /// Formats a relative time value.
+ ///
+ internal string Format(double value, string unit)
+ {
+ var absValue = System.Math.Abs(value);
+ // Handle negative zero: value < 0 is false for -0, so we check for negative infinity from 1/value
+ var isPast = value < 0 || double.IsNegativeInfinity(1.0 / value);
+ var intValue = (long) System.Math.Round(absValue);
+
+ // Handle "auto" numeric - special phrases for -1, 0, 1
+ if (string.Equals(Numeric, "auto", StringComparison.Ordinal))
+ {
+ var specialPhrase = GetSpecialPhrase(intValue, unit, isPast);
+ if (specialPhrase != null)
+ {
+ return specialPhrase;
+ }
+ }
+
+ // Format the number according to locale conventions using NumberFormat
+ // Per ECMA-402 17.5.2 step 11: Use PartitionNumberPattern for formatting
+ var formattedNumber = NumberFormat.Format(absValue);
+
+ // Try to get patterns from CLDR provider
+ var patterns = CldrProvider.GetRelativeTimePatterns(Locale, unit, Style);
+ if (patterns != null)
+ {
+ string pattern;
+
+ // Use plural rules if available
+ if (patterns.FuturePatterns != null || patterns.PastPatterns != null)
+ {
+ // Get plural form using PluralRules logic
+ var pluralForm = GetPluralForm(absValue);
+
+ var patternsDict = isPast ? patterns.PastPatterns : patterns.FuturePatterns;
+ if (patternsDict != null)
+ {
+ // Try exact plural form, fallback to "other"
+ if (!patternsDict.TryGetValue(pluralForm, out pattern!))
+ {
+ pattern = patternsDict.TryGetValue("other", out var fallback) ? fallback : "";
+ }
+ }
+ else
+ {
+ pattern = "";
+ }
+ }
+ else
+ {
+ // Legacy: simple plural check (backwards compatibility)
+ var plural = absValue != 1;
+ pattern = isPast
+ ? (plural ? patterns.PastPlural : patterns.Past)
+ : (plural ? patterns.FuturePlural : patterns.Future);
+ }
+
+ var result = pattern.Replace("{0}", formattedNumber);
+ return Data.NumberingSystemData.TransliterateDigits(result, NumberingSystem);
+ }
+
+ // Fallback to hardcoded patterns
+ var isEnglish = Locale.StartsWith("en", StringComparison.OrdinalIgnoreCase);
+ var unitName = GetUnitName(unit, absValue != 1);
+
+ if (isEnglish)
+ {
+ if (isPast)
+ {
+ return Data.NumberingSystemData.TransliterateDigits($"{formattedNumber} {unitName} ago", NumberingSystem);
+ }
+ return Data.NumberingSystemData.TransliterateDigits($"in {formattedNumber} {unitName}", NumberingSystem);
+ }
+
+ // For non-English, use a simple format
+ if (isPast)
+ {
+ return Data.NumberingSystemData.TransliterateDigits($"-{formattedNumber} {unitName}", NumberingSystem);
+ }
+ return Data.NumberingSystemData.TransliterateDigits($"+{formattedNumber} {unitName}", NumberingSystem);
+ }
+
+ ///
+ /// Formats a relative time value and returns parts.
+ ///
+ internal JsArray FormatToParts(Engine engine, double value, string unit)
+ {
+ var result = new JsArray(engine);
+ uint index = 0;
+
+ var absValue = System.Math.Abs(value);
+ // Handle negative zero: value < 0 is false for -0, so we check for negative infinity from 1/value
+ var isPast = value < 0 || double.IsNegativeInfinity(1.0 / value);
+ var intValue = (long) System.Math.Round(absValue);
+
+ // Handle "auto" numeric - special phrases
+ if (string.Equals(Numeric, "auto", StringComparison.Ordinal))
+ {
+ var specialPhrase = GetSpecialPhrase(intValue, unit, isPast);
+ if (specialPhrase != null)
+ {
+ var literalPart = OrdinaryObjectCreate(engine, engine.Realm.Intrinsics.Object.PrototypeObject);
+ literalPart.Set("type", "literal");
+ literalPart.Set("value", specialPhrase);
+ result.SetIndexValue(index++, literalPart, updateLength: true);
+ return result;
+ }
+ }
+
+ // Try to get patterns from CLDR provider
+ var patterns = CldrProvider.GetRelativeTimePatterns(Locale, unit, Style);
+ if (patterns != null)
+ {
+ string pattern;
+
+ // Use plural rules if available
+ if (patterns.FuturePatterns != null || patterns.PastPatterns != null)
+ {
+ // Get plural form using PluralRules logic
+ var pluralForm = GetPluralForm(absValue);
+
+ var patternsDict = isPast ? patterns.PastPatterns : patterns.FuturePatterns;
+ if (patternsDict != null)
+ {
+ // Try exact plural form, fallback to "other"
+ if (!patternsDict.TryGetValue(pluralForm, out pattern!))
+ {
+ pattern = patternsDict.TryGetValue("other", out var fallback) ? fallback : "";
+ }
+ }
+ else
+ {
+ pattern = "";
+ }
+ }
+ else
+ {
+ // Legacy: simple plural check (backwards compatibility)
+ var plural = absValue != 1;
+ pattern = isPast
+ ? (plural ? patterns.PastPlural : patterns.Past)
+ : (plural ? patterns.FuturePlural : patterns.Future);
+ }
+
+ // Parse the pattern and create parts
+ // Pattern format: "in {0} days" or "{0} days ago"
+ FormatPatternToParts(engine, result, ref index, pattern, absValue, unit);
+ return result;
+ }
+
+ // Fallback to hardcoded patterns
+ var isEnglish = Locale.StartsWith("en", StringComparison.OrdinalIgnoreCase);
+ var unitName = GetUnitName(unit, absValue != 1);
+
+ if (isEnglish)
+ {
+ if (isPast)
+ {
+ // "X units ago"
+ AddNumberParts(engine, result, ref index, absValue, unit, CultureInfo, NumberingSystem);
+ AddLiteralPart(engine, result, ref index, $" {unitName} ago");
+ }
+ else
+ {
+ // "in X units"
+ AddLiteralPart(engine, result, ref index, "in ");
+ AddNumberParts(engine, result, ref index, absValue, unit, CultureInfo, NumberingSystem);
+ AddLiteralPart(engine, result, ref index, $" {unitName}");
+ }
+ }
+ else
+ {
+ // Simple format for non-English
+ if (isPast)
+ {
+ AddLiteralPart(engine, result, ref index, "-");
+ }
+ else
+ {
+ AddLiteralPart(engine, result, ref index, "+");
+ }
+ AddNumberParts(engine, result, ref index, absValue, unit, CultureInfo, NumberingSystem);
+ AddLiteralPart(engine, result, ref index, $" {unitName}");
+ }
+
+ return result;
+ }
+
+ ///
+ /// Formats a pattern like "in {0} days" or "{0} days ago" to parts.
+ ///
+ private void FormatPatternToParts(Engine engine, JsArray result, ref uint index, string pattern, double value, string unit)
+ {
+ const string placeholder = "{0}";
+ var placeholderIndex = pattern.IndexOf(placeholder, StringComparison.Ordinal);
+
+ if (placeholderIndex < 0)
+ {
+ // No placeholder found, treat as literal
+ AddLiteralPart(engine, result, ref index, pattern);
+ return;
+ }
+
+ // Add literal before placeholder
+ if (placeholderIndex > 0)
+ {
+ AddLiteralPart(engine, result, ref index, pattern.Substring(0, placeholderIndex));
+ }
+
+ // Add number parts
+ AddNumberParts(engine, result, ref index, value, unit, CultureInfo, NumberingSystem);
+
+ // Add literal after placeholder
+ var afterPlaceholder = placeholderIndex + placeholder.Length;
+ if (afterPlaceholder < pattern.Length)
+ {
+ AddLiteralPart(engine, result, ref index, pattern.Substring(afterPlaceholder));
+ }
+ }
+
+ private static void AddLiteralPart(Engine engine, JsArray result, ref uint index, string value)
+ {
+ var part = OrdinaryObjectCreate(engine, engine.Realm.Intrinsics.Object.PrototypeObject);
+ part.Set("type", "literal");
+ part.Set("value", value);
+ result.SetIndexValue(index++, part, updateLength: true);
+ }
+
+ ///
+ /// Adds number parts using NumberFormat (per ECMA-402 17.5.2 step 11: use PartitionNumberPattern).
+ ///
+ private void AddNumberParts(Engine engine, JsArray result, ref uint index, double value, string unit, CultureInfo cultureInfo, string numberingSystem)
+ {
+ // Use NumberFormat to get properly formatted parts with grouping separators
+ var numberParts = NumberFormat.FormatToParts(value);
+
+ // Copy parts from NumberFormat, adding "unit" property to each
+ foreach (var numberPart in numberParts)
+ {
+ var part = OrdinaryObjectCreate(engine, engine.Realm.Intrinsics.Object.PrototypeObject);
+ part.Set("type", numberPart.Type);
+ part.Set("value", numberPart.Value);
+ part.Set("unit", unit);
+ result.SetIndexValue(index++, part, updateLength: true);
+ }
+ }
+
+ private static void AddIntegerPart(Engine engine, JsArray result, ref uint index, string value, string unit)
+ {
+ var part = OrdinaryObjectCreate(engine, engine.Realm.Intrinsics.Object.PrototypeObject);
+ part.Set("type", "integer");
+ part.Set("value", value);
+ part.Set("unit", unit);
+ result.SetIndexValue(index++, part, updateLength: true);
+ }
+
+ private static void AddGroupPart(Engine engine, JsArray result, ref uint index, string value, string unit)
+ {
+ var part = OrdinaryObjectCreate(engine, engine.Realm.Intrinsics.Object.PrototypeObject);
+ part.Set("type", "group");
+ part.Set("value", value);
+ part.Set("unit", unit);
+ result.SetIndexValue(index++, part, updateLength: true);
+ }
+
+ private static void AddDecimalPart(Engine engine, JsArray result, ref uint index, string value, string unit)
+ {
+ var part = OrdinaryObjectCreate(engine, engine.Realm.Intrinsics.Object.PrototypeObject);
+ part.Set("type", "decimal");
+ part.Set("value", value);
+ part.Set("unit", unit);
+ result.SetIndexValue(index++, part, updateLength: true);
+ }
+
+ private static void AddFractionPart(Engine engine, JsArray result, ref uint index, string value, string unit)
+ {
+ var part = OrdinaryObjectCreate(engine, engine.Realm.Intrinsics.Object.PrototypeObject);
+ part.Set("type", "fraction");
+ part.Set("value", value);
+ part.Set("unit", unit);
+ result.SetIndexValue(index++, part, updateLength: true);
+ }
+
+ private string? GetSpecialPhrase(long value, string unit, bool isPast)
+ {
+ // Try to get special phrase from CLDR provider
+ var phrase = CldrProvider.GetRelativeTimeSpecialPhrase(Locale, unit, (int) value, isPast, Style);
+ if (phrase != null)
+ {
+ return phrase;
+ }
+
+ // Fallback to hardcoded English phrases
+ if (!Locale.StartsWith("en", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ // English special phrases per CLDR
+ // second/minute/hour only have special form for 0
+ // day/week/month/quarter/year have special forms for -1, 0, 1
+ if (value == 0)
+ {
+ return unit switch
+ {
+ "second" => "now",
+ "minute" => "this minute",
+ "hour" => "this hour",
+ "day" => "today",
+ "week" => "this week",
+ "month" => "this month",
+ "quarter" => "this quarter",
+ "year" => "this year",
+ _ => null
+ };
+ }
+
+ if (value == 1)
+ {
+ if (isPast)
+ {
+ return unit switch
+ {
+ "day" => "yesterday",
+ "week" => "last week",
+ "month" => "last month",
+ "quarter" => "last quarter",
+ "year" => "last year",
+ _ => null
+ };
+ }
+ return unit switch
+ {
+ "day" => "tomorrow",
+ "week" => "next week",
+ "month" => "next month",
+ "quarter" => "next quarter",
+ "year" => "next year",
+ _ => null
+ };
+ }
+
+ return null;
+ }
+
+ ///
+ /// Gets the plural form for a value using simplified plural rules.
+ /// This implements a subset of CLDR plural rules for the locales we support.
+ ///
+ private string GetPluralForm(double value)
+ {
+ // Get integer and fraction parts
+ var i = (long) System.Math.Abs(System.Math.Truncate(value));
+ var v = GetFractionDigitCount(value);
+
+ // Extract language code from locale
+ var language = Locale;
+ var dashIndex = Locale.IndexOf('-');
+ if (dashIndex > 0)
+ {
+ language = Locale.Substring(0, dashIndex).ToLowerInvariant();
+ }
+ else
+ {
+ language = Locale.ToLowerInvariant();
+ }
+
+ // Apply language-specific plural rules (cardinal)
+ // Based on CLDR plural rules: https://cldr.unicode.org/index/cldr-spec/plural-rules
+ switch (language)
+ {
+ case "pl": // Polish
+ if (v != 0)
+ {
+ return "other";
+ }
+ if (i == 1)
+ {
+ return "one";
+ }
+ var mod10 = i % 10;
+ var mod100 = i % 100;
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
+ {
+ return "few";
+ }
+ if (mod10 == 0 || mod10 == 1 || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 12 && mod100 <= 14))
+ {
+ return "many";
+ }
+ return "other";
+
+ case "en": // English
+ case "de": // German
+ case "es": // Spanish
+ default:
+ // Simple rule: one if i == 1 and v == 0, otherwise other
+ if (i == 1 && v == 0)
+ {
+ return "one";
+ }
+ return "other";
+ }
+ }
+
+ ///
+ /// Gets the number of fraction digits in a value.
+ ///
+ private static int GetFractionDigitCount(double value)
+ {
+ var absValue = System.Math.Abs(value);
+ var intPart = System.Math.Truncate(absValue);
+ var fracPart = absValue - intPart;
+
+ if (fracPart == 0)
+ {
+ return 0;
+ }
+
+ var fracStr = fracPart.ToString("0.###############", CultureInfo.InvariantCulture);
+ if (fracStr.StartsWith("0.", StringComparison.Ordinal))
+ {
+ return fracStr.Length - 2; // Subtract "0."
+ }
+
+ return 0;
+ }
+
+ private string GetUnitName(string unit, bool plural)
+ {
+ var isShort = string.Equals(Style, "short", StringComparison.Ordinal);
+ var isNarrow = string.Equals(Style, "narrow", StringComparison.Ordinal);
+
+ if (isNarrow)
+ {
+ return unit switch
+ {
+ "second" or "seconds" => "s",
+ "minute" or "minutes" => "m",
+ "hour" or "hours" => "h",
+ "day" or "days" => "d",
+ "week" or "weeks" => "w",
+ "month" or "months" => "mo",
+ "quarter" or "quarters" => "q",
+ "year" or "years" => "y",
+ _ => unit
+ };
+ }
+
+ if (isShort)
+ {
+ return unit switch
+ {
+ "second" or "seconds" => "sec.",
+ "minute" or "minutes" => "min.",
+ "hour" or "hours" => "hr.",
+ "day" => plural ? "days" : "day",
+ "days" => "days",
+ "week" or "weeks" => "wk.",
+ "month" or "months" => "mo.",
+ "quarter" => plural ? "qtrs." : "qtr.",
+ "quarters" => "qtrs.",
+ "year" or "years" => "yr.",
+ _ => unit
+ };
+ }
+
+ // Long style
+ return unit switch
+ {
+ "second" => plural ? "seconds" : "second",
+ "seconds" => "seconds",
+ "minute" => plural ? "minutes" : "minute",
+ "minutes" => "minutes",
+ "hour" => plural ? "hours" : "hour",
+ "hours" => "hours",
+ "day" => plural ? "days" : "day",
+ "days" => "days",
+ "week" => plural ? "weeks" : "week",
+ "weeks" => "weeks",
+ "month" => plural ? "months" : "month",
+ "months" => "months",
+ "quarter" => plural ? "quarters" : "quarter",
+ "quarters" => "quarters",
+ "year" => plural ? "years" : "year",
+ "years" => "years",
+ _ => unit
+ };
+ }
+}
diff --git a/Jint/Native/Intl/JsSegmenter.cs b/Jint/Native/Intl/JsSegmenter.cs
new file mode 100644
index 0000000000..a6261491a6
--- /dev/null
+++ b/Jint/Native/Intl/JsSegmenter.cs
@@ -0,0 +1,47 @@
+using System.Globalization;
+using Jint.Native.Object;
+
+namespace Jint.Native.Intl;
+
+///
+/// https://tc39.es/ecma402/#sec-segmenter-objects
+/// Represents an Intl.Segmenter instance for locale-aware text segmentation.
+///
+internal sealed class JsSegmenter : ObjectInstance
+{
+ internal JsSegmenter(
+ Engine engine,
+ ObjectInstance prototype,
+ string locale,
+ string granularity,
+ CultureInfo cultureInfo) : base(engine)
+ {
+ _prototype = prototype;
+ Locale = locale;
+ Granularity = granularity;
+ CultureInfo = cultureInfo;
+ }
+
+ ///
+ /// The locale used for segmentation.
+ ///
+ internal string Locale { get; }
+
+ ///
+ /// The granularity: "grapheme", "word", or "sentence".
+ ///
+ internal string Granularity { get; }
+
+ ///
+ /// The .NET CultureInfo for locale-specific formatting.
+ ///
+ internal CultureInfo CultureInfo { get; }
+
+ ///
+ /// Segments the input string and returns a Segments object.
+ ///
+ internal JsSegments Segment(Engine engine, string input)
+ {
+ return new JsSegments(engine, this, input);
+ }
+}
diff --git a/Jint/Native/Intl/JsSegments.cs b/Jint/Native/Intl/JsSegments.cs
new file mode 100644
index 0000000000..b2c2a2c8b5
--- /dev/null
+++ b/Jint/Native/Intl/JsSegments.cs
@@ -0,0 +1,327 @@
+#pragma warning disable CA1859 // Use concrete types when possible for improved performance -- iterator protocol requires JsValue
+
+using System.Globalization;
+using Jint.Native.Iterator;
+using Jint.Native.Object;
+using Jint.Native.Symbol;
+using Jint.Runtime;
+using Jint.Runtime.Descriptors;
+using Jint.Runtime.Interop;
+
+namespace Jint.Native.Intl;
+
+///
+/// https://tc39.es/ecma402/#sec-segments-objects
+/// Represents the Segments object returned by Intl.Segmenter.prototype.segment().
+///
+internal sealed class JsSegments : ObjectInstance
+{
+ private readonly JsSegmenter _segmenter;
+ private readonly string _input;
+ private readonly List _segments;
+
+ internal JsSegments(Engine engine, JsSegmenter segmenter, string input) : base(engine)
+ {
+ _segmenter = segmenter;
+ _input = input;
+ _segments = ComputeSegments(input, segmenter.Granularity);
+ }
+
+ protected override void Initialize()
+ {
+ var properties = new PropertyDictionary(1, checkExistingKeys: false)
+ {
+ ["containing"] = new PropertyDescriptor(new ClrFunction(_engine, "containing", Containing, 1, PropertyFlag.Configurable), PropertyFlag.Writable | PropertyFlag.Configurable)
+ };
+ SetProperties(properties);
+
+ var symbols = new SymbolDictionary(1)
+ {
+ [GlobalSymbolRegistry.Iterator] = new PropertyDescriptor(new ClrFunction(_engine, "[Symbol.iterator]", GetIterator, 0, PropertyFlag.Configurable), PropertyFlag.Writable | PropertyFlag.Configurable)
+ };
+ SetSymbols(symbols);
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-%segmentsprototype%.containing
+ ///
+ private JsValue Containing(JsValue thisObject, JsCallArguments arguments)
+ {
+ // 1. Let segments be the this value.
+ // 2. Perform ? RequireInternalSlot(segments, [[SegmentsSegmenter]]).
+ if (thisObject is not JsSegments segments)
+ {
+ Throw.TypeError(_engine.Realm, "containing requires a Segments object");
+ return JsValue.Undefined;
+ }
+
+ var index = arguments.At(0);
+ var n = TypeConverter.ToInteger(index);
+
+ if (n < 0 || n >= segments._input.Length)
+ {
+ return JsValue.Undefined;
+ }
+
+ // Find the segment containing this index
+ var intIndex = (int) n;
+ foreach (var segment in segments._segments)
+ {
+ if (intIndex >= segment.Index && intIndex < segment.Index + segment.Segment.Length)
+ {
+ return segments.CreateSegmentDataObject(segment);
+ }
+ }
+
+ return JsValue.Undefined;
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-%segmentsprototype%-@@iterator
+ ///
+ private JsValue GetIterator(JsValue thisObject, JsCallArguments arguments)
+ {
+ return new SegmentIterator(_engine, this);
+ }
+
+ internal IEnumerable GetSegments() => _segments;
+
+ internal JsObject CreateSegmentDataObject(SegmentData data)
+ {
+ var obj = OrdinaryObjectCreate(_engine, _engine.Realm.Intrinsics.Object.PrototypeObject);
+ obj.Set("segment", data.Segment);
+ obj.Set("index", data.Index);
+ obj.Set("input", _input);
+
+ if (string.Equals(_segmenter.Granularity, "word", StringComparison.Ordinal))
+ {
+ obj.Set("isWordLike", data.IsWordLike);
+ }
+
+ return obj;
+ }
+
+ private static List ComputeSegments(string input, string granularity)
+ {
+ var segments = new List();
+
+ if (string.IsNullOrEmpty(input))
+ {
+ return segments;
+ }
+
+ return granularity switch
+ {
+ "grapheme" => ComputeGraphemeSegments(input),
+ "word" => ComputeWordSegments(input),
+ "sentence" => ComputeSentenceSegments(input),
+ _ => ComputeGraphemeSegments(input)
+ };
+ }
+
+ private static List ComputeGraphemeSegments(string input)
+ {
+ var segments = new List();
+ var enumerator = StringInfo.GetTextElementEnumerator(input);
+
+ while (enumerator.MoveNext())
+ {
+ segments.Add(new SegmentData
+ {
+ Segment = enumerator.GetTextElement(),
+ Index = enumerator.ElementIndex,
+ IsWordLike = false
+ });
+ }
+
+ return segments;
+ }
+
+ private static List ComputeWordSegments(string input)
+ {
+ var segments = new List();
+
+ // First, get grapheme clusters
+ var graphemes = new List();
+ var enumerator = StringInfo.GetTextElementEnumerator(input);
+ while (enumerator.MoveNext())
+ {
+ graphemes.Add(new GraphemeInfo(enumerator.GetTextElement(), enumerator.ElementIndex));
+ }
+
+ if (graphemes.Count == 0)
+ {
+ return segments;
+ }
+
+ var i = 0;
+ while (i < graphemes.Count)
+ {
+ var grapheme = graphemes[i].Text;
+ var startIndex = graphemes[i].Index;
+ var firstChar = grapheme[0];
+
+ if (char.IsWhiteSpace(firstChar))
+ {
+ // Whitespace segment - group consecutive whitespace graphemes
+ var endIndex = startIndex + grapheme.Length;
+ i++;
+ while (i < graphemes.Count && graphemes[i].Text.Length > 0 && char.IsWhiteSpace(graphemes[i].Text[0]))
+ {
+ endIndex = graphemes[i].Index + graphemes[i].Text.Length;
+ i++;
+ }
+ segments.Add(new SegmentData
+ {
+ Segment = input.Substring(startIndex, endIndex - startIndex),
+ Index = startIndex,
+ IsWordLike = false
+ });
+ }
+ else if (char.IsLetterOrDigit(firstChar) || char.GetUnicodeCategory(firstChar) == UnicodeCategory.ModifierLetter)
+ {
+ // Word segment - group consecutive word-like graphemes
+ var endIndex = startIndex + grapheme.Length;
+ i++;
+ while (i < graphemes.Count)
+ {
+ var nextGrapheme = graphemes[i].Text;
+ if (nextGrapheme.Length == 0) break;
+ var nextChar = nextGrapheme[0];
+ // Include letters, digits, apostrophe, hyphen, and modifier marks
+ if (char.IsLetterOrDigit(nextChar) ||
+ nextChar == '\'' || nextChar == '-' ||
+ char.GetUnicodeCategory(nextChar) == UnicodeCategory.ModifierLetter ||
+ char.GetUnicodeCategory(nextChar) == UnicodeCategory.NonSpacingMark)
+ {
+ endIndex = graphemes[i].Index + nextGrapheme.Length;
+ i++;
+ }
+ // Include decimal point or comma when between digits (for numbers like 1.23 or 1,000)
+ else if ((nextChar == '.' || nextChar == ',') && i + 1 < graphemes.Count)
+ {
+ var afterNext = graphemes[i + 1].Text;
+ if (afterNext.Length > 0 && char.IsDigit(afterNext[0]))
+ {
+ // Include the decimal/comma and the next digit
+ endIndex = graphemes[i + 1].Index + afterNext.Length;
+ i += 2;
+ }
+ else
+ {
+ break;
+ }
+ }
+ else
+ {
+ break;
+ }
+ }
+ segments.Add(new SegmentData
+ {
+ Segment = input.Substring(startIndex, endIndex - startIndex),
+ Index = startIndex,
+ IsWordLike = true
+ });
+ }
+ else
+ {
+ // Punctuation/other - each grapheme is its own segment
+ segments.Add(new SegmentData
+ {
+ Segment = grapheme,
+ Index = startIndex,
+ IsWordLike = false
+ });
+ i++;
+ }
+ }
+
+ return segments;
+ }
+
+ private readonly struct GraphemeInfo
+ {
+ public readonly string Text;
+ public readonly int Index;
+
+ public GraphemeInfo(string text, int index)
+ {
+ Text = text;
+ Index = index;
+ }
+ }
+
+ private static List ComputeSentenceSegments(string input)
+ {
+ var segments = new List();
+ var index = 0;
+ var length = input.Length;
+
+ while (index < length)
+ {
+ var startIndex = index;
+
+ // Find end of sentence (., !, ?)
+ while (index < length)
+ {
+ var c = input[index];
+ index++;
+
+ if (c == '.' || c == '!' || c == '?')
+ {
+ // Include trailing whitespace in the sentence
+ while (index < length && char.IsWhiteSpace(input[index]))
+ {
+ index++;
+ }
+ break;
+ }
+ }
+
+ segments.Add(new SegmentData
+ {
+ Segment = input.Substring(startIndex, index - startIndex),
+ Index = startIndex,
+ IsWordLike = false
+ });
+ }
+
+ return segments;
+ }
+
+ internal struct SegmentData
+ {
+ public string Segment;
+ public int Index;
+ public bool IsWordLike;
+ }
+
+ ///
+ /// Iterator for Segments object.
+ ///
+ private sealed class SegmentIterator : IteratorInstance
+ {
+ private readonly JsSegments _segments;
+ private readonly IEnumerator _enumerator;
+
+ public SegmentIterator(Engine engine, JsSegments segments) : base(engine)
+ {
+ _segments = segments;
+ _enumerator = segments.GetSegments().GetEnumerator();
+ }
+
+ public override bool TryIteratorStep(out ObjectInstance result)
+ {
+ if (_enumerator.MoveNext())
+ {
+ var segmentData = _segments.CreateSegmentDataObject(_enumerator.Current);
+ result = new Iterator.IteratorResult(_engine, segmentData, JsBoolean.False);
+ return true;
+ }
+
+ result = new Iterator.IteratorResult(_engine, JsValue.Undefined, JsBoolean.True);
+ return false;
+ }
+ }
+}
diff --git a/Jint/Native/Intl/ListFormatConstructor.cs b/Jint/Native/Intl/ListFormatConstructor.cs
index a88d880467..ec76acb67c 100644
--- a/Jint/Native/Intl/ListFormatConstructor.cs
+++ b/Jint/Native/Intl/ListFormatConstructor.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,9 @@ namespace Jint.Native.Intl;
internal sealed class ListFormatConstructor : Constructor
{
private static readonly JsString _functionName = new("ListFormat");
+ private static readonly HashSet LocaleMatcherValues = ["lookup", "best fit"];
+ private static readonly HashSet TypeValues = ["conjunction", "disjunction", "unit"];
+ private static readonly HashSet StyleValues = ["long", "short", "narrow"];
public ListFormatConstructor(
Engine engine,
@@ -24,10 +29,120 @@ public ListFormatConstructor(
_prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden);
}
+ 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);
+ }
+
public ListFormatPrototype PrototypeObject { get; }
+ ///
+ /// Called when Intl.ListFormat is invoked without `new`.
+ ///
+ protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments)
+ {
+ Throw.TypeError(_realm, "Constructor Intl.ListFormat requires 'new'");
+ return Undefined;
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.listformat
+ ///
public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget)
{
- throw new NotImplementedException();
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Get options object (strict - throws TypeError for non-object)
+ var optionsObj = IntlUtilities.GetOptionsObject(_engine, options);
+
+ // Per ECMA-402 13.1.1: Get options in the correct order
+ // Step 8: localeMatcher
+ var localeMatcher = GetStringOption(optionsObj, "localeMatcher", LocaleMatcherValues, "best fit");
+
+ // Step 10: type
+ var type = GetStringOption(optionsObj, "type", TypeValues, "conjunction");
+
+ // Step 12: style
+ var style = GetStringOption(optionsObj, "style", StyleValues, "long");
+
+ // Resolve locale (don't re-read localeMatcher from options)
+ var requestedLocales = IntlUtilities.CanonicalizeLocaleList(_engine, locales);
+ var availableLocales = IntlUtilities.GetAvailableLocales();
+ var resolvedLocale = ResolveListFormatLocale(_engine, availableLocales, requestedLocales, localeMatcher);
+
+ // Get CultureInfo for the locale
+ var culture = IntlUtilities.GetCultureInfo(resolvedLocale) ?? CultureInfo.InvariantCulture;
+
+ // Get prototype from newTarget (for cross-realm construction)
+ var proto = GetPrototypeFromConstructor(newTarget, static intrinsics => intrinsics.ListFormat.PrototypeObject);
+
+ return new JsListFormat(
+ _engine,
+ proto,
+ resolvedLocale,
+ type,
+ style,
+ culture);
+ }
+
+ 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 static string ResolveListFormatLocale(Engine engine, HashSet availableLocales, List requestedLocales, string localeMatcher)
+ {
+ var resolved = IntlUtilities.ResolveLocale(engine, availableLocales, requestedLocales, localeMatcher, []);
+ return resolved.Locale;
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.listformat.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");
+
+ 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/ListFormatPrototype.cs b/Jint/Native/Intl/ListFormatPrototype.cs
index 623f27c519..90cb85e66c 100644
--- a/Jint/Native/Intl/ListFormatPrototype.cs
+++ b/Jint/Native/Intl/ListFormatPrototype.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,9 +24,15 @@ public ListFormatPrototype(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;
+
+ var properties = new PropertyDictionary(4, checkExistingKeys: false)
{
- ["constructor"] = new PropertyDescriptor(_constructor, true, false, true),
+ ["constructor"] = new PropertyDescriptor(_constructor, PropertyFlag.NonEnumerable),
+ ["format"] = new PropertyDescriptor(new ClrFunction(Engine, "format", Format, 1, LengthFlags), PropertyFlags),
+ ["formatToParts"] = new PropertyDescriptor(new ClrFunction(Engine, "formatToParts", FormatToParts, 1, LengthFlags), PropertyFlags),
+ ["resolvedOptions"] = new PropertyDescriptor(new ClrFunction(Engine, "resolvedOptions", ResolvedOptions, 0, LengthFlags), PropertyFlags),
};
SetProperties(properties);
@@ -35,4 +42,193 @@ protected override void Initialize()
};
SetSymbols(symbols);
}
+
+ private JsListFormat ValidateListFormat(JsValue thisObject)
+ {
+ if (thisObject is JsListFormat listFormat)
+ {
+ return listFormat;
+ }
+
+ Throw.TypeError(_realm, "Value is not an Intl.ListFormat");
+ return null!; // Never reached
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.listformat.prototype.format
+ ///
+ private JsValue Format(JsValue thisObject, JsCallArguments arguments)
+ {
+ var listFormat = ValidateListFormat(thisObject);
+ var list = arguments.At(0);
+
+ var stringList = StringListFromIterable(list);
+ return listFormat.Format(stringList);
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.listformat.prototype.formattoparts
+ ///
+ private JsArray FormatToParts(JsValue thisObject, JsCallArguments arguments)
+ {
+ var listFormat = ValidateListFormat(thisObject);
+ var list = arguments.At(0);
+
+ var stringList = StringListFromIterable(list);
+ return listFormat.FormatToParts(Engine, stringList);
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.listformat.prototype.resolvedoptions
+ ///
+ private JsObject ResolvedOptions(JsValue thisObject, JsCallArguments arguments)
+ {
+ var listFormat = ValidateListFormat(thisObject);
+
+ var result = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject);
+
+ result.CreateDataPropertyOrThrow("locale", listFormat.Locale);
+ result.CreateDataPropertyOrThrow("type", listFormat.ListType);
+ result.CreateDataPropertyOrThrow("style", listFormat.Style);
+
+ return result;
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-createstringlistfromiterable
+ ///
+ private string[] StringListFromIterable(JsValue iterable)
+ {
+ if (iterable.IsUndefined())
+ {
+ return [];
+ }
+
+ var list = new List();
+
+ // Handle strings - iterate over each character
+ if (iterable.IsString())
+ {
+ var str = TypeConverter.ToString(iterable);
+ foreach (var c in str)
+ {
+ list.Add(c.ToString());
+ }
+ return list.ToArray();
+ }
+
+ // Handle array-like objects
+ if (iterable is JsArray jsArray)
+ {
+ var length = jsArray.Length;
+ for (uint i = 0; i < length; i++)
+ {
+ var element = jsArray.Get(i);
+ // Per ECMA-402 13.5.2 step 5.a: If Type(next) is not String, throw a TypeError
+ if (!element.IsString())
+ {
+ Throw.TypeError(_realm, "Iterable yielded a non-String value");
+ }
+ list.Add(element.AsString());
+ }
+ }
+ else if (iterable.IsObject())
+ {
+ // Handle generic iterables
+ var obj = iterable.AsObject();
+ var iteratorMethod = obj.Get(GlobalSymbolRegistry.Iterator);
+ if (!iteratorMethod.IsUndefined() && !iteratorMethod.IsNull())
+ {
+ if (iteratorMethod is not ICallable callable)
+ {
+ Throw.TypeError(_realm, "Iterator is not callable");
+ return null!;
+ }
+
+ var iteratorObj = callable.Call(obj, []);
+ if (iteratorObj.IsObject())
+ {
+ var iteratorInstance = iteratorObj.AsObject();
+ while (true)
+ {
+ var nextMethod = iteratorInstance.Get("next");
+ if (nextMethod is not ICallable nextCallable)
+ {
+ Throw.TypeError(_realm, "Iterator next is not callable");
+ return null!;
+ }
+
+ var next = nextCallable.Call(iteratorInstance, []);
+ if (next.IsObject())
+ {
+ var done = next.AsObject().Get("done");
+ if (TypeConverter.ToBoolean(done))
+ {
+ break;
+ }
+
+ var value = next.AsObject().Get("value");
+ // Per ECMA-402 13.5.2 step 5.a: If Type(next) is not String, then
+ // i. Let error be ThrowCompletion(a newly created TypeError object).
+ // ii. Return ? IteratorClose(iteratorRecord, error).
+ if (!value.IsString())
+ {
+ // Call IteratorClose before throwing
+ IteratorClose(iteratorInstance);
+ Throw.TypeError(_realm, "Iterable yielded a non-String value");
+ }
+ list.Add(value.AsString());
+ }
+ else
+ {
+ break;
+ }
+ }
+ }
+ }
+ else
+ {
+ // Array-like object with length property
+ var length = TypeConverter.ToLength(obj.Get("length"));
+ for (ulong i = 0; i < length; i++)
+ {
+ var element = obj.Get(i.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ // Per ECMA-402 13.5.2 step 5.a: If Type(next) is not String, throw a TypeError
+ if (!element.IsString())
+ {
+ Throw.TypeError(_realm, "Iterable yielded a non-String value");
+ }
+ list.Add(element.AsString());
+ }
+ }
+ }
+ else
+ {
+ Throw.TypeError(_realm, "Argument is not iterable");
+ }
+
+ return list.ToArray();
+ }
+
+ ///
+ /// https://tc39.es/ecma262/#sec-iteratorclose
+ /// Calls the iterator's return method if it exists.
+ ///
+ private static void IteratorClose(ObjectInstance iterator)
+ {
+ // 1. Let return be ? GetMethod(iterator, "return").
+ var returnMethod = iterator.Get("return");
+
+ // 2. If return is undefined, return NormalCompletion(empty).
+ if (returnMethod.IsUndefined() || returnMethod.IsNull())
+ {
+ return;
+ }
+
+ // 3. Call return method
+ if (returnMethod is ICallable callable)
+ {
+ callable.Call(iterator, []);
+ }
+ }
}
diff --git a/Jint/Native/Intl/RelativeTimeFormatConstructor.cs b/Jint/Native/Intl/RelativeTimeFormatConstructor.cs
index f16548ddb6..3c0b8f1433 100644
--- a/Jint/Native/Intl/RelativeTimeFormatConstructor.cs
+++ b/Jint/Native/Intl/RelativeTimeFormatConstructor.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,9 @@ namespace Jint.Native.Intl;
internal sealed class RelativeTimeFormatConstructor : Constructor
{
private static readonly JsString _functionName = new("RelativeTimeFormat");
+ private static readonly string[] LocaleMatcherValues = ["lookup", "best fit"];
+ private static readonly string[] StyleValues = ["long", "short", "narrow"];
+ private static readonly string[] NumericValues = ["always", "auto"];
public RelativeTimeFormatConstructor(
Engine engine,
@@ -24,32 +29,348 @@ public RelativeTimeFormatConstructor(
_prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden);
}
+ 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);
+ }
+
public RelativeTimeFormatPrototype PrototypeObject { get; }
+ ///
+ /// Called when Intl.RelativeTimeFormat is invoked without `new`.
+ ///
+ protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments)
+ {
+ Throw.TypeError(_realm, "Constructor Intl.RelativeTimeFormat requires 'new'");
+ return Undefined;
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.relativetimeformat
+ ///
public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget)
{
var locales = arguments.At(0);
var options = arguments.At(1);
- if (newTarget.IsUndefined())
+ // Get options object (lenient - converts to object)
+ var optionsObj = IntlUtilities.CoerceOptionsToObject(_engine, options);
+
+ // Per spec: Get options in the correct order
+ // Step 5: localeMatcher
+ var localeMatcher = GetStringOption(optionsObj, "localeMatcher", LocaleMatcherValues, "best fit");
+
+ // Step 7: numberingSystem (read and validate)
+ // Per spec, the value must be syntactically valid as a Unicode numbering system identifier
+ // If not supported, we fall back to "latn" - we don't throw for valid-but-unsupported values
+ var numberingSystemValue = optionsObj.Get("numberingSystem");
+ string? numberingSystem = null;
+ if (!numberingSystemValue.IsUndefined())
+ {
+ numberingSystem = TypeConverter.ToString(numberingSystemValue);
+ if (string.IsNullOrEmpty(numberingSystem) || !IsWellFormedNumberingSystem(numberingSystem))
+ {
+ Throw.RangeError(_realm, $"Invalid numberingSystem: {numberingSystem}");
+ }
+ }
+
+ // Step 16: style
+ var style = GetStringOption(optionsObj, "style", StyleValues, "long");
+
+ // Step 18: numeric
+ var numeric = GetStringOption(optionsObj, "numeric", NumericValues, "always");
+
+ // Resolve locale (don't re-read localeMatcher from options)
+ var requestedLocales = IntlUtilities.CanonicalizeLocaleList(_engine, locales);
+ var availableLocales = IntlUtilities.GetAvailableLocales();
+ var resolvedLocale = ResolveRelativeTimeFormatLocale(_engine, availableLocales, requestedLocales, localeMatcher);
+
+ // Resolve numbering system with proper fallback logic
+ string? localeNumberingSystem = null;
+ foreach (var loc in requestedLocales)
+ {
+ localeNumberingSystem = ExtractNumberingSystemFromLocale(loc);
+ if (localeNumberingSystem != null)
+ {
+ break;
+ }
+ }
+
+ string resolvedNumberingSystem;
+ if (numberingSystem != null && IsSupportedNumberingSystem(numberingSystem))
+ {
+ // Options value is valid and supported - use it
+ resolvedNumberingSystem = numberingSystem;
+ }
+ else if (localeNumberingSystem != null && IsSupportedNumberingSystem(localeNumberingSystem))
{
- newTarget = this;
+ // Fall back to locale extension value
+ resolvedNumberingSystem = localeNumberingSystem;
+ }
+ else
+ {
+ // Default to "latn"
+ resolvedNumberingSystem = "latn";
}
- var relativeTimeFormat = OrdinaryCreateFromConstructor(
- newTarget,
- static intrinsics => intrinsics.RelativeTimeFormat.PrototypeObject,
- static (engine, _, _) => new JsObject(engine));
+ // Adjust the resolved locale based on numbering system source
+ // Per spec:
+ // - If options.numberingSystem overrides locale extension with different value, remove nu from locale
+ // - If options.numberingSystem matches locale extension, keep the extension
+ // - If locale extension is used (no valid options value), keep the extension
+ var finalResolvedLocale = resolvedLocale;
+ var numberingSystemFromOptions = numberingSystem != null && IsSupportedNumberingSystem(numberingSystem);
- InitializeRelativeTimeFormat(relativeTimeFormat, locales, options);
- return relativeTimeFormat;
+ if (numberingSystemFromOptions)
+ {
+ // Check if the options value matches the locale extension
+ if (localeNumberingSystem != null &&
+ string.Equals(numberingSystem, localeNumberingSystem, StringComparison.OrdinalIgnoreCase))
+ {
+ // Options matches locale extension - keep the extension
+ finalResolvedLocale = EnsureNumberingSystemInLocale(resolvedLocale, resolvedNumberingSystem);
+ }
+ else
+ {
+ // Options overrode locale extension with different value - remove nu from resolved locale
+ finalResolvedLocale = RemoveNumberingSystemFromLocale(resolvedLocale);
+ }
+ }
+ else if (localeNumberingSystem != null && IsSupportedNumberingSystem(localeNumberingSystem))
+ {
+ // Locale extension is used - ensure it's in the resolved locale
+ finalResolvedLocale = EnsureNumberingSystemInLocale(resolvedLocale, resolvedNumberingSystem);
+ }
+ else
+ {
+ // Default is used - remove any unsupported nu extension
+ finalResolvedLocale = RemoveNumberingSystemFromLocale(resolvedLocale);
+ }
+
+ // Get CultureInfo for the locale
+ var culture = IntlUtilities.GetCultureInfo(finalResolvedLocale) ?? CultureInfo.InvariantCulture;
+
+ // Get prototype from newTarget (for cross-realm construction)
+ var proto = GetPrototypeFromConstructor(newTarget, static intrinsics => intrinsics.RelativeTimeFormat.PrototypeObject);
+
+ // Per ECMA-402 17.1.1 step 24: Create NumberFormat for number formatting
+ var numberFormatConstructor = (NumberFormatConstructor) _realm.Intrinsics.NumberFormat;
+ var numberFormat = (JsNumberFormat) numberFormatConstructor.Construct([new JsString(finalResolvedLocale), Undefined], numberFormatConstructor);
+
+ return new JsRelativeTimeFormat(
+ _engine,
+ proto,
+ finalResolvedLocale,
+ resolvedNumberingSystem,
+ style,
+ numeric,
+ culture,
+ numberFormat);
+ }
+
+ private static string? ExtractNumberingSystemFromLocale(string locale)
+ {
+ // Look for -u-nu-xxx pattern
+ const string marker = "-u-";
+ var uIndex = locale.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
+ if (uIndex == -1) return null;
+
+ // Look for nu- after -u-
+ var extensionPart = locale.Substring(uIndex + marker.Length);
+ var nuIndex = extensionPart.IndexOf("nu-", StringComparison.OrdinalIgnoreCase);
+ if (nuIndex == -1) return null;
+
+ // Extract the value after nu-
+ var valueStart = nuIndex + 3;
+ var valueEnd = valueStart;
+ while (valueEnd < extensionPart.Length && extensionPart[valueEnd] != '-')
+ {
+ valueEnd++;
+ }
+
+ if (valueEnd > valueStart)
+ {
+ return extensionPart.Substring(valueStart, valueEnd - valueStart).ToLowerInvariant();
+ }
+
+ return null;
+ }
+
+ private static bool IsWellFormedNumberingSystem(string numberingSystem)
+ {
+ // Unicode numbering system identifier can be a sequence of subtags separated by '-'
+ // Each subtag must be 3-8 alphanumeric characters
+ if (string.IsNullOrEmpty(numberingSystem))
+ {
+ return false;
+ }
+
+ var subtags = numberingSystem.Split('-');
+ foreach (var subtag in subtags)
+ {
+ if (subtag.Length < 3 || subtag.Length > 8)
+ {
+ return false;
+ }
+
+ foreach (var c in subtag)
+ {
+ if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')))
+ {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsSupportedNumberingSystem(string numberingSystem)
+ {
+ // Check if the numbering system is actually supported (has digit mappings)
+ return Data.NumberingSystemData.Digits.ContainsKey(numberingSystem);
+ }
+
+ private static string RemoveNumberingSystemFromLocale(string locale)
+ {
+ // Remove the -u-nu-xxx or -nu-xxx part from the locale
+ var uIndex = locale.IndexOf("-u-", StringComparison.OrdinalIgnoreCase);
+ if (uIndex == -1) return locale;
+
+ var nuIndex = locale.IndexOf("-nu-", StringComparison.OrdinalIgnoreCase);
+ if (nuIndex == -1) return locale;
+
+ // Find the end of the nu value
+ var valueStart = nuIndex + 4;
+ var valueEnd = valueStart;
+ while (valueEnd < locale.Length && locale[valueEnd] != '-')
+ {
+ valueEnd++;
+ }
+
+ // Check if there are other extensions after nu
+ var hasOtherExtensions = valueEnd < locale.Length;
+
+ // Check if there are other extensions before nu (after -u-)
+ var extensionPart = locale.Substring(uIndex + 3);
+ var hasExtensionsBefore = !extensionPart.StartsWith("nu-", StringComparison.OrdinalIgnoreCase);
+
+ if (!hasOtherExtensions && !hasExtensionsBefore)
+ {
+ // nu is the only extension - remove entire -u- section
+ return locale.Substring(0, uIndex);
+ }
+
+ // Remove just the nu-xxx part
+#pragma warning disable CA1845
+ return locale.Substring(0, nuIndex) + locale.Substring(valueEnd);
+#pragma warning restore CA1845
+ }
+
+ private static string EnsureNumberingSystemInLocale(string locale, string numberingSystem)
+ {
+ // Check if locale already has the numbering system extension
+ var uIndex = locale.IndexOf("-u-", StringComparison.OrdinalIgnoreCase);
+ if (uIndex == -1)
+ {
+ // No Unicode extension - add it
+ return locale + "-u-nu-" + numberingSystem;
+ }
+
+ // Check if nu is already present with the correct value
+ var nuPattern = "-nu-" + numberingSystem;
+ if (locale.Contains(nuPattern, StringComparison.OrdinalIgnoreCase))
+ {
+ return locale;
+ }
+
+ // Check if nu is present at all
+ var nuIndex = locale.IndexOf("-nu-", StringComparison.OrdinalIgnoreCase);
+ if (nuIndex == -1)
+ {
+ // Insert nu after -u-
+ return locale.Insert(uIndex + 3, "nu-" + numberingSystem + "-");
+ }
+
+ // nu exists with different value - replace it
+ var valueStart = nuIndex + 4;
+ var valueEnd = valueStart;
+ while (valueEnd < locale.Length && locale[valueEnd] != '-')
+ {
+ valueEnd++;
+ }
+#pragma warning disable CA1845
+ return locale.Substring(0, valueStart) + numberingSystem + locale.Substring(valueEnd);
+#pragma warning restore CA1845
+ }
+
+ private string GetStringOption(ObjectInstance options, string property, string[]? values, string fallback)
+ {
+ var value = options.Get(property);
+ if (value.IsUndefined())
+ {
+ return fallback;
+ }
+
+ var stringValue = TypeConverter.ToString(value);
+
+ if (values != null && values.Length > 0)
+ {
+ var found = false;
+ foreach (var allowed in values)
+ {
+ if (string.Equals(stringValue, allowed, StringComparison.Ordinal))
+ {
+ found = true;
+ break;
+ }
+ }
+
+ if (!found)
+ {
+ Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option '{property}'");
+ }
+ }
+
+ return stringValue;
+ }
+
+ private static string ResolveRelativeTimeFormatLocale(Engine engine, HashSet availableLocales, List requestedLocales, string localeMatcher)
+ {
+ var resolved = IntlUtilities.ResolveLocale(engine, availableLocales, requestedLocales, localeMatcher, []);
+ return resolved.Locale;
}
///
- /// https://tc39.es/ecma402/#sec-InitializeRelativeTimeFormat
+ /// https://tc39.es/ecma402/#sec-intl.relativetimeformat.supportedlocalesof
///
- private static void InitializeRelativeTimeFormat(JsObject relativeTimeFormat, JsValue locales, JsValue options)
+ private JsArray SupportedLocalesOf(JsValue thisObject, JsCallArguments arguments)
{
- // TODO
+ 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");
+
+ 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/RelativeTimeFormatPrototype.cs b/Jint/Native/Intl/RelativeTimeFormatPrototype.cs
index c34eed505b..ed3619ee52 100644
--- a/Jint/Native/Intl/RelativeTimeFormatPrototype.cs
+++ b/Jint/Native/Intl/RelativeTimeFormatPrototype.cs
@@ -2,6 +2,7 @@
using Jint.Native.Symbol;
using Jint.Runtime;
using Jint.Runtime.Descriptors;
+using Jint.Runtime.Interop;
namespace Jint.Native.Intl;
@@ -10,6 +11,8 @@ namespace Jint.Native.Intl;
///
internal sealed class RelativeTimeFormatPrototype : Prototype
{
+ private static readonly string[] ValidUnits = ["second", "seconds", "minute", "minutes", "hour", "hours", "day", "days", "week", "weeks", "month", "months", "quarter", "quarters", "year", "years"];
+
private readonly RelativeTimeFormatConstructor _constructor;
public RelativeTimeFormatPrototype(
@@ -24,9 +27,15 @@ public RelativeTimeFormatPrototype(
protected override void Initialize()
{
- var properties = new PropertyDictionary(2, checkExistingKeys: false)
+ const PropertyFlag LengthFlags = PropertyFlag.Configurable;
+ const PropertyFlag PropertyFlags = PropertyFlag.Writable | PropertyFlag.Configurable;
+
+ var properties = new PropertyDictionary(4, checkExistingKeys: false)
{
- ["constructor"] = new PropertyDescriptor(_constructor, true, false, true),
+ ["constructor"] = new PropertyDescriptor(_constructor, PropertyFlag.NonEnumerable),
+ ["format"] = new PropertyDescriptor(new ClrFunction(Engine, "format", Format, 2, LengthFlags), PropertyFlags),
+ ["formatToParts"] = new PropertyDescriptor(new ClrFunction(Engine, "formatToParts", FormatToParts, 2, LengthFlags), PropertyFlags),
+ ["resolvedOptions"] = new PropertyDescriptor(new ClrFunction(Engine, "resolvedOptions", ResolvedOptions, 0, LengthFlags), PropertyFlags),
};
SetProperties(properties);
@@ -36,4 +45,108 @@ protected override void Initialize()
};
SetSymbols(symbols);
}
+
+ private JsRelativeTimeFormat ValidateRelativeTimeFormat(JsValue thisObject)
+ {
+ if (thisObject is JsRelativeTimeFormat relativeTimeFormat)
+ {
+ return relativeTimeFormat;
+ }
+
+ Throw.TypeError(_realm, "Value is not an Intl.RelativeTimeFormat");
+ return null!; // Never reached
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.relativetimeformat.prototype.format
+ ///
+ private JsValue Format(JsValue thisObject, JsCallArguments arguments)
+ {
+ var relativeTimeFormat = ValidateRelativeTimeFormat(thisObject);
+ var value = arguments.At(0);
+ var unit = arguments.At(1);
+
+ var numericValue = TypeConverter.ToNumber(value);
+ if (double.IsNaN(numericValue) || double.IsInfinity(numericValue))
+ {
+ Throw.RangeError(_realm, "Invalid value");
+ }
+
+ var unitString = TypeConverter.ToString(unit);
+ var normalizedUnit = NormalizeUnit(unitString);
+ if (normalizedUnit == null)
+ {
+ Throw.RangeError(_realm, $"Invalid unit: {unitString}");
+ }
+
+ return relativeTimeFormat.Format(numericValue, normalizedUnit);
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.relativetimeformat.prototype.formattoparts
+ ///
+ private JsArray FormatToParts(JsValue thisObject, JsCallArguments arguments)
+ {
+ var relativeTimeFormat = ValidateRelativeTimeFormat(thisObject);
+ var value = arguments.At(0);
+ var unit = arguments.At(1);
+
+ var numericValue = TypeConverter.ToNumber(value);
+ if (double.IsNaN(numericValue) || double.IsInfinity(numericValue))
+ {
+ Throw.RangeError(_realm, "Invalid value");
+ }
+
+ var unitString = TypeConverter.ToString(unit);
+ var normalizedUnit = NormalizeUnit(unitString);
+ if (normalizedUnit == null)
+ {
+ Throw.RangeError(_realm, $"Invalid unit: {unitString}");
+ }
+
+ return relativeTimeFormat.FormatToParts(_engine, numericValue, normalizedUnit);
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.relativetimeformat.prototype.resolvedoptions
+ ///
+ private JsObject ResolvedOptions(JsValue thisObject, JsCallArguments arguments)
+ {
+ var relativeTimeFormat = ValidateRelativeTimeFormat(thisObject);
+
+ var result = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject);
+
+ result.CreateDataPropertyOrThrow("locale", relativeTimeFormat.Locale);
+ result.CreateDataPropertyOrThrow("style", relativeTimeFormat.Style);
+ result.CreateDataPropertyOrThrow("numeric", relativeTimeFormat.Numeric);
+ result.CreateDataPropertyOrThrow("numberingSystem", relativeTimeFormat.NumberingSystem);
+
+ return result;
+ }
+
+ private static string? NormalizeUnit(string unit)
+ {
+ // Check if it's a valid unit
+ foreach (var validUnit in ValidUnits)
+ {
+ if (string.Equals(unit, validUnit, StringComparison.Ordinal))
+ {
+ // Normalize to singular form
+ return unit switch
+ {
+ "seconds" => "second",
+ "minutes" => "minute",
+ "hours" => "hour",
+ "days" => "day",
+ "weeks" => "week",
+ "months" => "month",
+ "quarters" => "quarter",
+ "years" => "year",
+ _ => unit
+ };
+ }
+ }
+
+ return null;
+ }
}
diff --git a/Jint/Native/Intl/SegmenterConstructor.cs b/Jint/Native/Intl/SegmenterConstructor.cs
index b3d6aecf28..ed02b9616e 100644
--- a/Jint/Native/Intl/SegmenterConstructor.cs
+++ b/Jint/Native/Intl/SegmenterConstructor.cs
@@ -1,7 +1,11 @@
+#pragma warning disable CA1859 // Use concrete types when possible for improved performance -- ClrFunction requires JsValue
+
+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 +15,8 @@ namespace Jint.Native.Intl;
internal sealed class SegmenterConstructor : Constructor
{
private static readonly JsString _functionName = new("Segmenter");
+ private static readonly string[] LocaleMatcherValues = ["lookup", "best fit"];
+ private static readonly string[] GranularityValues = ["grapheme", "word", "sentence"];
public SegmenterConstructor(
Engine engine,
@@ -24,10 +30,120 @@ public SegmenterConstructor(
_prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden);
}
- public SegmenterPrototype 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);
+ }
+
+ private SegmenterPrototype PrototypeObject { get; }
+
+ ///
+ /// Called when Intl.Segmenter is invoked without `new`.
+ ///
+ protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments)
+ {
+ Throw.TypeError(_realm, "Constructor Intl.Segmenter requires 'new'");
+ return Undefined;
+ }
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.segmenter
+ ///
public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget)
{
- throw new NotImplementedException();
+ var locales = arguments.At(0);
+ var options = arguments.At(1);
+
+ // Get options object (strict - throws TypeError for non-object)
+ var optionsObj = IntlUtilities.GetOptionsObject(_engine, options);
+
+ // Per spec: Get options in the correct order
+ // Step 6: localeMatcher
+ var localeMatcher = GetStringOption(optionsObj, "localeMatcher", LocaleMatcherValues, "best fit");
+
+ // Step 11: granularity
+ var granularity = GetStringOption(optionsObj, "granularity", GranularityValues, "grapheme");
+
+ // Resolve locale (don't re-read localeMatcher from options)
+ var requestedLocales = IntlUtilities.CanonicalizeLocaleList(_engine, locales);
+ var availableLocales = IntlUtilities.GetAvailableLocales();
+ var resolved = IntlUtilities.ResolveLocale(_engine, availableLocales, requestedLocales, localeMatcher, []);
+
+ // Get CultureInfo for the locale
+ var culture = IntlUtilities.GetCultureInfo(resolved.Locale) ?? CultureInfo.InvariantCulture;
+
+ // Get prototype from newTarget (for cross-realm construction)
+ var proto = GetPrototypeFromConstructor(newTarget, static intrinsics => intrinsics.Segmenter.PrototypeObject);
+
+ return new JsSegmenter(
+ _engine,
+ proto,
+ resolved.Locale,
+ granularity,
+ culture);
+ }
+
+ private string GetStringOption(ObjectInstance options, string property, string[]? values, string fallback)
+ {
+ var value = options.Get(property);
+ if (value.IsUndefined())
+ {
+ return fallback;
+ }
+
+ var stringValue = TypeConverter.ToString(value);
+
+ if (values != null && values.Length > 0)
+ {
+ var found = false;
+ foreach (var allowed in values)
+ {
+ if (string.Equals(stringValue, allowed, StringComparison.Ordinal))
+ {
+ found = true;
+ break;
+ }
+ }
+
+ if (!found)
+ {
+ Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option '{property}'");
+ }
+ }
+
+ return stringValue;
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.segmenter.supportedlocalesof
+ ///
+ private JsValue 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");
+
+ var supported = new List();
+ 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/SegmenterPrototype.cs b/Jint/Native/Intl/SegmenterPrototype.cs
index 0d924ac775..e8582e67c6 100644
--- a/Jint/Native/Intl/SegmenterPrototype.cs
+++ b/Jint/Native/Intl/SegmenterPrototype.cs
@@ -1,7 +1,10 @@
+#pragma warning disable CA1859 // Use concrete types when possible for improved performance -- prototype methods return JsValue
+
using Jint.Native.Object;
using Jint.Native.Symbol;
using Jint.Runtime;
using Jint.Runtime.Descriptors;
+using Jint.Runtime.Interop;
namespace Jint.Native.Intl;
@@ -24,9 +27,14 @@ public SegmenterPrototype(
protected override void Initialize()
{
- var properties = new PropertyDictionary(2, checkExistingKeys: false)
+ const PropertyFlag LengthFlags = PropertyFlag.Configurable;
+ const PropertyFlag PropertyFlags = PropertyFlag.Writable | PropertyFlag.Configurable;
+
+ var properties = new PropertyDictionary(3, checkExistingKeys: false)
{
- ["constructor"] = new PropertyDescriptor(_constructor, true, false, true),
+ ["constructor"] = new PropertyDescriptor(_constructor, PropertyFlag.NonEnumerable),
+ ["segment"] = new PropertyDescriptor(new ClrFunction(Engine, "segment", Segment, 1, LengthFlags), PropertyFlags),
+ ["resolvedOptions"] = new PropertyDescriptor(new ClrFunction(Engine, "resolvedOptions", ResolvedOptions, 0, LengthFlags), PropertyFlags),
};
SetProperties(properties);
@@ -36,4 +44,42 @@ protected override void Initialize()
};
SetSymbols(symbols);
}
+
+ private JsSegmenter ValidateSegmenter(JsValue thisObject)
+ {
+ if (thisObject is JsSegmenter segmenter)
+ {
+ return segmenter;
+ }
+
+ Throw.TypeError(_realm, "Value is not an Intl.Segmenter");
+ return null!; // Never reached
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.segmenter.prototype.segment
+ ///
+ private JsValue Segment(JsValue thisObject, JsCallArguments arguments)
+ {
+ var segmenter = ValidateSegmenter(thisObject);
+ var input = arguments.At(0);
+
+ var stringInput = TypeConverter.ToString(input);
+ return segmenter.Segment(_engine, stringInput);
+ }
+
+ ///
+ /// https://tc39.es/ecma402/#sec-intl.segmenter.prototype.resolvedoptions
+ ///
+ private JsValue ResolvedOptions(JsValue thisObject, JsCallArguments arguments)
+ {
+ var segmenter = ValidateSegmenter(thisObject);
+
+ var result = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject);
+
+ result.CreateDataPropertyOrThrow("locale", segmenter.Locale);
+ result.CreateDataPropertyOrThrow("granularity", segmenter.Granularity);
+
+ return result;
+ }
}
diff --git a/Jint/Runtime/Intrinsics.Intl.cs b/Jint/Runtime/Intrinsics.Intl.cs
index 0f2b6caf58..2b48aada64 100644
--- a/Jint/Runtime/Intrinsics.Intl.cs
+++ b/Jint/Runtime/Intrinsics.Intl.cs
@@ -8,6 +8,7 @@ public sealed partial class Intrinsics
private CollatorConstructor? _collator;
private DateTimeFormatConstructor? _dateTimeFormat;
private DisplayNamesConstructor? _displayNames;
+ private DurationFormatConstructor? _durationFormat;
private ListFormatConstructor? _listFormat;
private LocaleConstructor? _locale;
private NumberFormatConstructor? _numberFormat;
@@ -27,6 +28,9 @@ public sealed partial class Intrinsics
internal DisplayNamesConstructor DisplayNames =>
_displayNames ??= new DisplayNamesConstructor(_engine, _realm, Function.PrototypeObject, Object.PrototypeObject);
+ internal DurationFormatConstructor DurationFormat =>
+ _durationFormat ??= new DurationFormatConstructor(_engine, _realm, Function.PrototypeObject, Object.PrototypeObject);
+
internal ListFormatConstructor ListFormat =>
_listFormat ??= new ListFormatConstructor(_engine, _realm, Function.PrototypeObject, Object.PrototypeObject);