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);