From 04f8fb07e1add4b2b9856b73e8be770f6078f817 Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Wed, 8 Dec 2021 19:57:21 -0500 Subject: [PATCH] LibJS: Implement Intl.DateTimeFormat.prototype.formatRange --- .../LibJS/Runtime/CommonPropertyNames.h | 1 + Userland/Libraries/LibJS/Runtime/ErrorTypes.h | 1 + .../LibJS/Runtime/Intl/DateTimeFormat.cpp | 345 ++++++++++++++++-- .../LibJS/Runtime/Intl/DateTimeFormat.h | 23 +- .../Runtime/Intl/DateTimeFormatPrototype.cpp | 28 ++ .../Runtime/Intl/DateTimeFormatPrototype.h | 1 + .../DateTimeFormat.prototype.formatRange.js | 220 +++++++++++ 7 files changed, 591 insertions(+), 28 deletions(-) create mode 100644 Userland/Libraries/LibJS/Tests/builtins/Intl/DateTimeFormat/DateTimeFormat.prototype.formatRange.js diff --git a/Userland/Libraries/LibJS/Runtime/CommonPropertyNames.h b/Userland/Libraries/LibJS/Runtime/CommonPropertyNames.h index 1794db808a..7237edcb80 100644 --- a/Userland/Libraries/LibJS/Runtime/CommonPropertyNames.h +++ b/Userland/Libraries/LibJS/Runtime/CommonPropertyNames.h @@ -179,6 +179,7 @@ namespace JS { P(forEach) \ P(format) \ P(formatMatcher) \ + P(formatRange) \ P(formatToParts) \ P(fractionalSecondDigits) \ P(freeze) \ diff --git a/Userland/Libraries/LibJS/Runtime/ErrorTypes.h b/Userland/Libraries/LibJS/Runtime/ErrorTypes.h index 1d6febbcbc..6ee6ee6d29 100644 --- a/Userland/Libraries/LibJS/Runtime/ErrorTypes.h +++ b/Userland/Libraries/LibJS/Runtime/ErrorTypes.h @@ -38,6 +38,7 @@ M(IntlInvalidDateTimeFormatOption, "Option {} cannot be set when also providing {}") \ M(IntlInvalidLanguageTag, "{} is not a structurally valid language tag") \ M(IntlInvalidTime, "Time value must be between -8.64E15 and 8.64E15") \ + M(IntlStartTimeAfterEndTime, "Start time {} is after end time {}") \ M(IntlMinimumExceedsMaximum, "Minimum value {} is larger than maximum value {}") \ M(IntlNumberIsNaNOrOutOfRange, "Value {} is NaN or is not between {} and {}") \ M(IntlOptionUndefined, "Option {} must be defined when option {} is {}") \ diff --git a/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormat.cpp b/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormat.cpp index 43ca8be0f5..61dd68e4eb 100644 --- a/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormat.cpp +++ b/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormat.cpp @@ -4,6 +4,7 @@ * SPDX-License-Identifier: BSD-2-Clause */ +#include #include #include #include @@ -716,10 +717,12 @@ struct StyleAndValue { i32 value { 0 }; }; -static Optional find_calendar_field(StringView name, DateTimeFormat const& date_time_format, LocalTime const& local_time) +static Optional find_calendar_field(StringView name, Unicode::CalendarPattern const& options, Unicode::CalendarPattern const* range_options, LocalTime const& local_time) { - auto make_style_and_value = [](auto name, auto style, auto value) { - return StyleAndValue { name, style, static_cast(value) }; + auto make_style_and_value = [](auto name, auto style, auto fallback_style, auto value) { + if (style.has_value()) + return StyleAndValue { name, *style, static_cast(value) }; + return StyleAndValue { name, fallback_style, static_cast(value) }; }; constexpr auto weekday = "weekday"sv; @@ -731,27 +734,42 @@ static Optional find_calendar_field(StringView name, DateTimeForm constexpr auto minute = "minute"sv; constexpr auto second = "second"sv; + Optional empty; + if (name == weekday) - return make_style_and_value(weekday, date_time_format.weekday(), local_time.weekday); + return make_style_and_value(weekday, range_options ? range_options->weekday : empty, *options.weekday, local_time.weekday); if (name == era) - return make_style_and_value(era, date_time_format.era(), local_time.era); + return make_style_and_value(era, range_options ? range_options->era : empty, *options.era, local_time.era); if (name == year) - return make_style_and_value(year, date_time_format.year(), local_time.year); + return make_style_and_value(year, range_options ? range_options->year : empty, *options.year, local_time.year); if (name == month) - return make_style_and_value(month, date_time_format.month(), local_time.month); + return make_style_and_value(month, range_options ? range_options->month : empty, *options.month, local_time.month); if (name == day) - return make_style_and_value(day, date_time_format.day(), local_time.day); + return make_style_and_value(day, range_options ? range_options->day : empty, *options.day, local_time.day); if (name == hour) - return make_style_and_value(hour, date_time_format.hour(), local_time.hour); + return make_style_and_value(hour, range_options ? range_options->hour : empty, *options.hour, local_time.hour); if (name == minute) - return make_style_and_value(minute, date_time_format.minute(), local_time.minute); + return make_style_and_value(minute, range_options ? range_options->minute : empty, *options.minute, local_time.minute); if (name == second) - return make_style_and_value(second, date_time_format.second(), local_time.second); + return make_style_and_value(second, range_options ? range_options->second : empty, *options.second, local_time.second); return {}; } +static Optional day_period_for_hour(StringView locale, StringView calendar, Unicode::CalendarPatternStyle style, u8 hour) +{ + // FIXME: This isn't locale-aware. We should parse the CLDR's cldr-core/supplemental/dayPeriods.json file + // to acquire day periods per-locale. For now, these are hard-coded to the en locale's values. + if ((hour >= 6) && (hour < 12)) + return Unicode::get_calendar_day_period_symbol(locale, calendar, style, Unicode::DayPeriod::Morning); + if ((hour >= 12) && (hour < 18)) + return Unicode::get_calendar_day_period_symbol(locale, calendar, style, Unicode::DayPeriod::Afternoon); + if ((hour >= 18) && (hour < 21)) + return Unicode::get_calendar_day_period_symbol(locale, calendar, style, Unicode::DayPeriod::Evening); + return Unicode::get_calendar_day_period_symbol(locale, calendar, style, Unicode::DayPeriod::Night); +} + // 11.1.7 FormatDateTimePattern ( dateTimeFormat, patternParts, x, rangeFormatOptions ), https://tc39.es/ecma402/#sec-formatdatetimepattern -ThrowCompletionOr> format_date_time_pattern(GlobalObject& global_object, DateTimeFormat& date_time_format, Vector pattern_parts, Value time, [[maybe_unused]] Value range_format_options) +ThrowCompletionOr> format_date_time_pattern(GlobalObject& global_object, DateTimeFormat& date_time_format, Vector pattern_parts, Value time, Unicode::CalendarPattern const* range_format_options) { auto& vm = global_object.vm(); @@ -851,24 +869,13 @@ ThrowCompletionOr> format_date_time_pattern(GlobalObjec // d. Else if p is equal to "dayPeriod", then else if (part == "dayPeriod"sv) { - Optional symbol; String formatted_value; // i. Let f be the value of dateTimeFormat's internal slot whose name is the Internal Slot column of the matching row. auto style = date_time_format.day_period(); // ii. Let fv be a String value representing the day period of tm in the form given by f; the String value depends upon the implementation and the effective locale of dateTimeFormat. - // FIXME: This isn't locale-aware. We should parse the CLDR's cldr-core/supplemental/dayPeriods.json file to acquire day periods - // per-locale. For now, these are hard-coded to the en locale's values. - if ((local_time.hour >= 6) && (local_time.hour < 12)) - symbol = Unicode::get_calendar_day_period_symbol(data_locale, date_time_format.calendar(), style, Unicode::DayPeriod::Morning); - else if ((local_time.hour >= 12) && (local_time.hour < 18)) - symbol = Unicode::get_calendar_day_period_symbol(data_locale, date_time_format.calendar(), style, Unicode::DayPeriod::Afternoon); - else if ((local_time.hour >= 18) && (local_time.hour < 21)) - symbol = Unicode::get_calendar_day_period_symbol(data_locale, date_time_format.calendar(), style, Unicode::DayPeriod::Evening); - else - symbol = Unicode::get_calendar_day_period_symbol(data_locale, date_time_format.calendar(), style, Unicode::DayPeriod::Night); - + auto symbol = day_period_for_hour(data_locale, date_time_format.calendar(), style, local_time.hour); if (symbol.has_value()) formatted_value = *symbol; @@ -893,12 +900,12 @@ ThrowCompletionOr> format_date_time_pattern(GlobalObjec } // f. Else if p matches a Property column of the row in Table 4, then - else if (auto style_and_value = find_calendar_field(part, date_time_format, local_time); style_and_value.has_value()) { + else if (auto style_and_value = find_calendar_field(part, date_time_format, range_format_options, local_time); style_and_value.has_value()) { String formatted_value; // i. If rangeFormatOptions is not undefined, let f be the value of rangeFormatOptions's field whose name matches p. // ii. Else, let f be the value of dateTimeFormat's internal slot whose name is the Internal Slot column of the matching row. - // FIXME: Implement step i when range format is supported. + // NOTE: find_calendar_field handles resolving rangeFormatOptions and dateTimeFormat fields. auto style = style_and_value->style; // iii. Let v be the value of tm's field whose name is the Internal Slot column of the matching row. @@ -1052,7 +1059,7 @@ ThrowCompletionOr> partition_date_time_pattern(GlobalOb auto pattern_parts = partition_pattern(date_time_format.pattern()); // 2. Let result be ? FormatDateTimePattern(dateTimeFormat, patternParts, x, undefined). - auto result = TRY(format_date_time_pattern(global_object, date_time_format, move(pattern_parts), time, js_undefined())); + auto result = TRY(format_date_time_pattern(global_object, date_time_format, move(pattern_parts), time, nullptr)); // 3. Return result. return result; @@ -1113,6 +1120,290 @@ ThrowCompletionOr format_date_time_to_parts(GlobalObject& global_object, return result; } +template +void for_each_range_pattern_field(LocalTime const& time1, LocalTime const& time2, Callback&& callback) +{ + // Table 6: Range pattern fields, https://tc39.es/ecma402/#table-datetimeformat-rangepatternfields + if (callback(static_cast(time1.era), static_cast(time2.era), Unicode::CalendarRangePattern::Field::Era) == IterationDecision::Break) + return; + if (callback(time1.year, time2.year, Unicode::CalendarRangePattern::Field::Year) == IterationDecision::Break) + return; + if (callback(time1.month, time2.month, Unicode::CalendarRangePattern::Field::Month) == IterationDecision::Break) + return; + if (callback(time1.day, time2.day, Unicode::CalendarRangePattern::Field::Day) == IterationDecision::Break) + return; + if (callback(time1.hour, time2.hour, Unicode::CalendarRangePattern::Field::AmPm) == IterationDecision::Break) + return; + if (callback(time1.hour, time2.hour, Unicode::CalendarRangePattern::Field::DayPeriod) == IterationDecision::Break) + return; + if (callback(time1.hour, time2.hour, Unicode::CalendarRangePattern::Field::Hour) == IterationDecision::Break) + return; + if (callback(time1.minute, time2.minute, Unicode::CalendarRangePattern::Field::Minute) == IterationDecision::Break) + return; + if (callback(time1.second, time2.second, Unicode::CalendarRangePattern::Field::Second) == IterationDecision::Break) + return; + if (callback(time1.millisecond, time2.millisecond, Unicode::CalendarRangePattern::Field::FractionalSecondDigits) == IterationDecision::Break) + return; +} + +template +ThrowCompletionOr for_each_range_pattern_with_source(Unicode::CalendarRangePattern& pattern, Callback&& callback) +{ + TRY(callback(pattern.start_range, "startRange"sv)); + TRY(callback(pattern.separator, "shared"sv)); + TRY(callback(pattern.end_range, "endRange"sv)); + return {}; +} + +// 11.1.11 PartitionDateTimeRangePattern ( dateTimeFormat, x, y ), https://tc39.es/ecma402/#sec-partitiondatetimerangepattern +ThrowCompletionOr> partition_date_time_range_pattern(GlobalObject& global_object, DateTimeFormat& date_time_format, Value start, Value end) +{ + auto& vm = global_object.vm(); + + // 1. Let x be TimeClip(x). + start = time_clip(global_object, start); + + // 2. If x is NaN, throw a RangeError exception. + if (start.is_nan()) + return vm.throw_completion(global_object, ErrorType::IntlInvalidTime); + + // 3. Let y be TimeClip(y). + end = time_clip(global_object, end); + + // 4. If y is NaN, throw a RangeError exception. + if (end.is_nan()) + return vm.throw_completion(global_object, ErrorType::IntlInvalidTime); + + // 5. If x is greater than y, throw a RangeError exception. + if (start.as_double() > end.as_double()) + return vm.throw_completion(global_object, ErrorType::IntlStartTimeAfterEndTime, start, end); + + // 6. Let tm1 be ToLocalTime(x, dateTimeFormat.[[Calendar]], dateTimeFormat.[[TimeZone]]). + auto start_local_time = TRY(to_local_time(global_object, start.as_double(), date_time_format.calendar(), date_time_format.time_zone())); + + // 7. Let tm2 be ToLocalTime(y, dateTimeFormat.[[Calendar]], dateTimeFormat.[[TimeZone]]). + auto end_local_time = TRY(to_local_time(global_object, end.as_double(), date_time_format.calendar(), date_time_format.time_zone())); + + // 8. Let rangePatterns be dateTimeFormat.[[RangePatterns]]. + auto range_patterns = date_time_format.range_patterns(); + + // 9. Let rangePattern be undefined. + Optional range_pattern; + + // 10. Let dateFieldsPracticallyEqual be true. + bool date_fields_practically_equal = true; + + // 11. Let patternContainsLargerDateField be false. + bool pattern_contains_larger_date_field = false; + + // 12. While dateFieldsPracticallyEqual is true and patternContainsLargerDateField is false, repeat for each row of Table 6 in order, except the header row: + for_each_range_pattern_field(start_local_time, end_local_time, [&](auto start_value, auto end_value, auto field_name) { + // a. Let fieldName be the name given in the Range Pattern Field column of the row. + + // b. If rangePatterns has a field [[]], let rp be rangePatterns.[[]]; else let rp be undefined. + Optional pattern; + for (auto const& range : range_patterns) { + if (range.field == field_name) { + pattern = range; + break; + } + } + + // c. If rangePattern is not undefined and rp is undefined, then + if (range_pattern.has_value() && !pattern.has_value()) { + // i. Set patternContainsLargerDateField to true. + pattern_contains_larger_date_field = true; + } + // d. Else, + else { + // i. Let rangePattern be rp. + range_pattern = pattern; + + switch (field_name) { + // ii. If fieldName is equal to [[AmPm]], then + case Unicode::CalendarRangePattern::Field::AmPm: { + // 1. Let v1 be tm1.[[Hour]]. + // 2. Let v2 be tm2.[[Hour]]. + + // 3. If v1 is greater than 11 and v2 less or equal than 11, or v1 is less or equal than 11 and v2 is greater than 11, then + if ((start_value > 11 && end_value <= 11) || (start_value <= 11 && end_value > 11)) { + // a. Set dateFieldsPracticallyEqual to false. + date_fields_practically_equal = false; + } + + break; + } + // iii. Else if fieldName is equal to [[DayPeriod]], then + case Unicode::CalendarRangePattern::Field::DayPeriod: { + // 1. Let v1 be a String value representing the day period of tm1; the String value depends upon the implementation and the effective locale of dateTimeFormat. + auto start_period = day_period_for_hour(date_time_format.data_locale(), date_time_format.calendar(), Unicode::CalendarPatternStyle::Short, start_value); + + // 2. Let v2 be a String value representing the day period of tm2; the String value depends upon the implementation and the effective locale of dateTimeFormat. + auto end_period = day_period_for_hour(date_time_format.data_locale(), date_time_format.calendar(), Unicode::CalendarPatternStyle::Short, end_value); + + // 3. If v1 is not equal to v2, then + if (start_period != end_period) { + // a. Set dateFieldsPracticallyEqual to false. + date_fields_practically_equal = false; + } + + break; + } + // iv. Else if fieldName is equal to [[FractionalSecondDigits]], then + case Unicode::CalendarRangePattern::Field::FractionalSecondDigits: { + // 1. Let fractionalSecondDigits be dateTimeFormat.[[FractionalSecondDigits]]. + Optional fractional_second_digits; + if (date_time_format.has_fractional_second_digits()) + fractional_second_digits = date_time_format.fractional_second_digits(); + + // 2. If fractionalSecondDigits is undefined, then + if (!fractional_second_digits.has_value()) { + // a. Set fractionalSecondDigits to 3. + fractional_second_digits = 3; + } + + // 3. Let v1 be tm1.[[Millisecond]]. + // 4. Let v2 be tm2.[[Millisecond]]. + + // 5. Let v1 be floor(v1 × 10( fractionalSecondDigits - 3 )). + start_value = floor(start_value * pow(10, static_cast(*fractional_second_digits) - 3)); + + // 6. Let v2 be floor(v2 × 10( fractionalSecondDigits - 3 )). + end_value = floor(end_value * pow(10, static_cast(*fractional_second_digits) - 3)); + + // 7. If v1 is not equal to v2, then + if (start_value != end_value) { + // a. Set dateFieldsPracticallyEqual to false. + date_fields_practically_equal = false; + } + + break; + } + + // v. Else, + default: { + // 1. Let v1 be tm1.[[]]. + // 2. Let v2 be tm2.[[]]. + + // 3. If v1 is not equal to v2, then + if (start_value != end_value) { + // a. Set dateFieldsPracticallyEqual to false. + date_fields_practically_equal = false; + } + + break; + } + } + } + + if (date_fields_practically_equal && !pattern_contains_larger_date_field) + return IterationDecision::Continue; + return IterationDecision::Break; + }); + + // 13. If dateFieldsPracticallyEqual is true, then + if (date_fields_practically_equal) { + // a. Let pattern be dateTimeFormat.[[Pattern]]. + auto const& pattern = date_time_format.pattern(); + + // b. Let patternParts be PartitionPattern(pattern). + auto pattern_parts = partition_pattern(pattern); + + // c. Let result be ? FormatDateTimePattern(dateTimeFormat, patternParts, x, undefined). + auto raw_result = TRY(format_date_time_pattern(global_object, date_time_format, move(pattern_parts), start, nullptr)); + auto result = PatternPartitionWithSource::create_from_parent_list(move(raw_result)); + + // d. For each Record { [[Type]], [[Value]] } r in result, do + for (auto& part : result) { + // i. Set r.[[Source]] to "shared". + part.source = "shared"sv; + } + + // e. Return result. + return result; + } + + // 14. Let result be a new empty List. + Vector result; + + // 15. If rangePattern is undefined, then + if (!range_pattern.has_value()) { + // a. Let rangePattern be rangePatterns.[[Default]]. + range_pattern = Unicode::get_calendar_default_range_format(date_time_format.data_locale(), date_time_format.calendar()); + + // Non-standard, range_pattern will be empty if Unicode data generation is disabled. + if (!range_pattern.has_value()) + return result; + + // Non-standard, LibUnicode leaves the CLDR's {0} and {1} partitions in the default patterns + // to be replaced at runtime with the DateTimeFormat object's pattern. + auto const& pattern = date_time_format.pattern(); + + if (range_pattern->start_range.contains("{0}"sv)) { + range_pattern->start_range = range_pattern->start_range.replace("{0}"sv, pattern); + range_pattern->end_range = range_pattern->end_range.replace("{1}"sv, pattern); + } else { + range_pattern->start_range = range_pattern->start_range.replace("{1}"sv, pattern); + range_pattern->end_range = range_pattern->end_range.replace("{0}"sv, pattern); + } + + // FIXME: The above is not sufficient. For example, if the start date is days before the end date, and only the timeStyle + // option is provided, the resulting range will not include the differing dates. We will likely need to implement + // step 3 here: https://unicode.org/reports/tr35/tr35-dates.html#intervalFormats + } + + // 16. For each Record { [[Pattern]], [[Source]] } rangePatternPart in rangePattern.[[PatternParts]], do + TRY(for_each_range_pattern_with_source(*range_pattern, [&](auto const& pattern, auto source) -> ThrowCompletionOr { + // a. Let pattern be rangePatternPart.[[Pattern]]. + // b. Let source be rangePatternPart.[[Source]]. + + // c. If source is "startRange" or "shared", then + // i. Let z be x. + // d. Else, + // i. Let z be y. + auto time = ((source == "startRange") || (source == "shared")) ? start : end; + + // e. Let patternParts be PartitionPattern(pattern). + auto pattern_parts = partition_pattern(pattern); + + // f. Let partResult be ? FormatDateTimePattern(dateTimeFormat, patternParts, z, rangePattern). + auto raw_part_result = TRY(format_date_time_pattern(global_object, date_time_format, move(pattern_parts), time, &range_pattern.value())); + auto part_result = PatternPartitionWithSource::create_from_parent_list(move(raw_part_result)); + + // g. For each Record { [[Type]], [[Value]] } r in partResult, do + for (auto& part : part_result) { + // i. Set r.[[Source]] to source. + part.source = source; + } + + // h. Add all elements in partResult to result in order. + result.extend(move(part_result)); + return {}; + })); + + // 17. Return result. + return result; +} + +// 11.1.12 FormatDateTimeRange ( dateTimeFormat, x, y ), https://tc39.es/ecma402/#sec-formatdatetimerange +ThrowCompletionOr format_date_time_range(GlobalObject& global_object, DateTimeFormat& date_time_format, Value start, Value end) +{ + // 1. Let parts be ? PartitionDateTimeRangePattern(dateTimeFormat, x, y). + auto parts = TRY(partition_date_time_range_pattern(global_object, date_time_format, start, end)); + + // 2. Let result be the empty String. + StringBuilder result; + + // 3. For each Record { [[Type]], [[Value]], [[Source]] } part in parts, do + for (auto& part : parts) { + // a. Set result to the string-concatenation of result and part.[[Value]]. + result.append(move(part.value)); + } + + // 4. Return result. + return result.build(); +} + // 11.1.14 ToLocalTime ( t, calendar, timeZone ), https://tc39.es/ecma402/#sec-tolocaltime ThrowCompletionOr to_local_time(GlobalObject& global_object, double time, StringView calendar, [[maybe_unused]] StringView time_zone) { diff --git a/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormat.h b/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormat.h index 4b45403ef5..bc99585490 100644 --- a/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormat.h +++ b/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormat.h @@ -175,15 +175,36 @@ struct LocalTime { bool in_dst { false }; // [[InDST]] }; +struct PatternPartitionWithSource : public PatternPartition { + static Vector create_from_parent_list(Vector partitions) + { + Vector result; + result.ensure_capacity(partitions.size()); + + for (auto& partition : partitions) { + PatternPartitionWithSource partition_with_source {}; + partition_with_source.type = partition.type; + partition_with_source.value = move(partition.value); + result.append(move(partition_with_source)); + } + + return result; + } + + StringView source; +}; + ThrowCompletionOr initialize_date_time_format(GlobalObject& global_object, DateTimeFormat& date_time_format, Value locales_value, Value options_value); ThrowCompletionOr to_date_time_options(GlobalObject& global_object, Value options_value, OptionRequired, OptionDefaults); Optional date_time_style_format(StringView data_locale, DateTimeFormat& date_time_format); Optional basic_format_matcher(Unicode::CalendarPattern const& options, Vector formats); Optional best_fit_format_matcher(Unicode::CalendarPattern const& options, Vector formats); -ThrowCompletionOr> format_date_time_pattern(GlobalObject& global_object, DateTimeFormat& date_time_format, Vector pattern_parts, Value time, Value range_format_options); +ThrowCompletionOr> format_date_time_pattern(GlobalObject& global_object, DateTimeFormat& date_time_format, Vector pattern_parts, Value time, Unicode::CalendarPattern const* range_format_options); ThrowCompletionOr> partition_date_time_pattern(GlobalObject& global_object, DateTimeFormat& date_time_format, Value time); ThrowCompletionOr format_date_time(GlobalObject& global_object, DateTimeFormat& date_time_format, Value time); ThrowCompletionOr format_date_time_to_parts(GlobalObject& global_object, DateTimeFormat& date_time_format, Value time); +ThrowCompletionOr> partition_date_time_range_pattern(GlobalObject& global_object, DateTimeFormat& date_time_format, Value start, Value end); +ThrowCompletionOr format_date_time_range(GlobalObject& global_object, DateTimeFormat& date_time_format, Value start, Value end); ThrowCompletionOr to_local_time(GlobalObject& global_object, double time, StringView calendar, StringView time_zone); template diff --git a/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormatPrototype.cpp b/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormatPrototype.cpp index ff86f1e42e..8361926f67 100644 --- a/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormatPrototype.cpp +++ b/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormatPrototype.cpp @@ -32,6 +32,7 @@ void DateTimeFormatPrototype::initialize(GlobalObject& global_object) u8 attr = Attribute::Writable | Attribute::Configurable; define_native_function(vm.names.formatToParts, format_to_parts, 1, attr); + define_native_function(vm.names.formatRange, format_range, 2, attr); define_native_function(vm.names.resolvedOptions, resolved_options, 0, attr); } @@ -82,6 +83,33 @@ JS_DEFINE_NATIVE_FUNCTION(DateTimeFormatPrototype::format_to_parts) return TRY(format_date_time_to_parts(global_object, *date_time_format, date)); } +// 11.4.5 Intl.DateTimeFormat.prototype.formatRange ( startDate, endDate ), https://tc39.es/ecma402/#sec-intl.datetimeformat.prototype.formatRange +JS_DEFINE_NATIVE_FUNCTION(DateTimeFormatPrototype::format_range) +{ + auto start_date = vm.argument(0); + auto end_date = vm.argument(1); + + // 1. Let dtf be this value. + // 2. Perform ? RequireInternalSlot(dtf, [[InitializedDateTimeFormat]]). + auto* date_time_format = TRY(typed_this_object(global_object)); + + // 3. If startDate is undefined or endDate is undefined, throw a TypeError exception. + if (start_date.is_undefined()) + return vm.throw_completion(global_object, ErrorType::IsUndefined, "startDate"sv); + if (end_date.is_undefined()) + return vm.throw_completion(global_object, ErrorType::IsUndefined, "endDate"sv); + + // 4. Let x be ? ToNumber(startDate). + start_date = TRY(start_date.to_number(global_object)); + + // 5. Let y be ? ToNumber(endDate). + end_date = TRY(end_date.to_number(global_object)); + + // 6. Return ? FormatDateTimeRange(dtf, x, y). + auto formatted = TRY(format_date_time_range(global_object, *date_time_format, start_date, end_date)); + return js_string(vm, move(formatted)); +} + // 11.4.7 Intl.DateTimeFormat.prototype.resolvedOptions ( ), https://tc39.es/ecma402/#sec-intl.datetimeformat.prototype.resolvedoptions JS_DEFINE_NATIVE_FUNCTION(DateTimeFormatPrototype::resolved_options) { diff --git a/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormatPrototype.h b/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormatPrototype.h index 6f0b467bd9..35b71f3aec 100644 --- a/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormatPrototype.h +++ b/Userland/Libraries/LibJS/Runtime/Intl/DateTimeFormatPrototype.h @@ -22,6 +22,7 @@ public: private: JS_DECLARE_NATIVE_FUNCTION(format); JS_DECLARE_NATIVE_FUNCTION(format_to_parts); + JS_DECLARE_NATIVE_FUNCTION(format_range); JS_DECLARE_NATIVE_FUNCTION(resolved_options); }; diff --git a/Userland/Libraries/LibJS/Tests/builtins/Intl/DateTimeFormat/DateTimeFormat.prototype.formatRange.js b/Userland/Libraries/LibJS/Tests/builtins/Intl/DateTimeFormat/DateTimeFormat.prototype.formatRange.js new file mode 100644 index 0000000000..9ebdce2535 --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/builtins/Intl/DateTimeFormat/DateTimeFormat.prototype.formatRange.js @@ -0,0 +1,220 @@ +describe("errors", () => { + test("called on non-DateTimeFormat object", () => { + expect(() => { + Intl.DateTimeFormat.prototype.formatRange(1, 2); + }).toThrowWithMessage(TypeError, "Not an object of type Intl.DateTimeFormat"); + }); + + test("called with undefined values", () => { + expect(() => { + Intl.DateTimeFormat().formatRange(); + }).toThrowWithMessage(TypeError, "startDate is undefined"); + + expect(() => { + Intl.DateTimeFormat().formatRange(1); + }).toThrowWithMessage(TypeError, "endDate is undefined"); + }); + + test("called with values that cannot be converted to numbers", () => { + expect(() => { + Intl.DateTimeFormat().formatRange(1, Symbol.hasInstance); + }).toThrowWithMessage(TypeError, "Cannot convert symbol to number"); + + expect(() => { + Intl.DateTimeFormat().formatRange(1n, 1); + }).toThrowWithMessage(TypeError, "Cannot convert BigInt to number"); + }); + + test("time value cannot be clipped", () => { + [NaN, -8.65e15, 8.65e15].forEach(d => { + expect(() => { + Intl.DateTimeFormat().formatRange(d, 1); + }).toThrowWithMessage(RangeError, "Time value must be between -8.64E15 and 8.64E15"); + + expect(() => { + Intl.DateTimeFormat().formatRange(1, d); + }).toThrowWithMessage(RangeError, "Time value must be between -8.64E15 and 8.64E15"); + }); + }); + + test("called with values in bad order", () => { + expect(() => { + Intl.DateTimeFormat().formatRange(new Date(2021), new Date(1989)); + }).toThrowWithMessage(RangeError, "Start time 2021 is after end time 1989"); + }); +}); + +const d0 = Date.UTC(1989, 0, 23, 7, 8, 9, 45); +const d1 = Date.UTC(2021, 11, 7, 17, 40, 50, 456); + +describe("equal dates are squashed", () => { + test("with date fields", () => { + const en = new Intl.DateTimeFormat("en", { + year: "numeric", + month: "long", + day: "2-digit", + }); + expect(en.formatRange(d0, d0)).toBe("January 23, 1989"); + expect(en.formatRange(d1, d1)).toBe("December 07, 2021"); + + const ja = new Intl.DateTimeFormat("ja", { + year: "numeric", + month: "long", + day: "2-digit", + }); + expect(ja.formatRange(d0, d0)).toBe("1989/1月/23"); + expect(ja.formatRange(d1, d1)).toBe("2021/12月/07"); + }); + + test("with time fields", () => { + const en = new Intl.DateTimeFormat("en", { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZone: "UTC", + }); + expect(en.formatRange(d0, d0)).toBe("7:08:09 AM"); + expect(en.formatRange(d1, d1)).toBe("5:40:50 PM"); + + const ja = new Intl.DateTimeFormat("ja", { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZone: "UTC", + }); + expect(ja.formatRange(d0, d0)).toBe("7:08:09"); + expect(ja.formatRange(d1, d1)).toBe("17:40:50"); + }); + + test("with mixed fields", () => { + const en = new Intl.DateTimeFormat("en", { + year: "numeric", + month: "long", + day: "2-digit", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZone: "UTC", + }); + expect(en.formatRange(d0, d0)).toBe("January 23, 1989 at 7:08:09 AM"); + expect(en.formatRange(d1, d1)).toBe("December 07, 2021 at 5:40:50 PM"); + + const ja = new Intl.DateTimeFormat("ja", { + year: "numeric", + month: "long", + day: "2-digit", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZone: "UTC", + }); + expect(ja.formatRange(d0, d0)).toBe("1989/1月/23 7:08:09"); + expect(ja.formatRange(d1, d1)).toBe("2021/12月/07 17:40:50"); + }); + + test("with date/time style fields", () => { + const en = new Intl.DateTimeFormat("en", { + dateStyle: "full", + timeStyle: "medium", + timeZone: "UTC", + }); + expect(en.formatRange(d0, d0)).toBe("Monday, January 23, 1989 at 7:08:09 AM"); + expect(en.formatRange(d1, d1)).toBe("Tuesday, December 7, 2021 at 5:40:50 PM"); + + const ja = new Intl.DateTimeFormat("ja", { + dateStyle: "full", + timeStyle: "medium", + timeZone: "UTC", + }); + expect(ja.formatRange(d0, d0)).toBe("1989年1月23日月曜日 7:08:09"); + expect(ja.formatRange(d1, d1)).toBe("2021年12月7日火曜日 17:40:50"); + }); +}); + +describe("dateStyle", () => { + // prettier-ignore + const data = [ + { date: "full", en: "Monday, January 23, 1989 – Tuesday, December 7, 2021", ja: "1989年1月23日月曜日~2021年12月7日火曜日" }, + { date: "long", en: "January 23, 1989 – December 7, 2021", ja: "1989/01/23~2021/12/07" }, + { date: "medium", en: "Jan 23, 1989 – Dec 7, 2021", ja: "1989/01/23~2021/12/07" }, + { date: "short", en: "1/23/89 – 12/7/21", ja: "1989/01/23~2021/12/07" }, + ]; + + test("all", () => { + data.forEach(d => { + const en = new Intl.DateTimeFormat("en", { dateStyle: d.date }); + expect(en.formatRange(d0, d1)).toBe(d.en); + + // If this test is to be changed, take care to note the "long" style for the ja locale is an intentionally + // chosen complex test case. The format pattern is "y年M月d日" and its skeleton is "yMd" - note that the + // month field has a numeric style. However, the interval patterns that match the "yMd" skeleton are all + // "y/MM/dd~y/MM/dd" - the month field there conflicts with a 2-digit style. This exercises the step in the + // FormatDateTimePattern AO to choose the style from rangeFormatOptions instead of dateTimeFormat (step 15.f.i + // as of when this test was written). + const ja = new Intl.DateTimeFormat("ja", { dateStyle: d.date }); + expect(ja.formatRange(d0, d1)).toBe(d.ja); + }); + }); +}); + +describe("timeStyle", () => { + // prettier-ignore + const data = [ + // FIXME: These results should include the date, even though it isn't requested, because the start/end dates + // are more than just hours apart. See the FIXME in PartitionDateTimeRangePattern. + { time: "full", en: "7:08:09 AM Coordinated Universal Time – 5:40:50 PM Coordinated Universal Time", ja: "7時08分09秒 協定世界時~17時40分50秒 協定世界時" }, + { time: "long", en: "7:08:09 AM UTC – 5:40:50 PM UTC", ja: "7:08:09 UTC~17:40:50 UTC" }, + { time: "medium", en: "7:08:09 AM – 5:40:50 PM", ja: "7:08:09~17:40:50" }, + { time: "short", en: "7:08 AM – 5:40 PM", ja: "7:08~17:40" }, + ]; + + test("all", () => { + data.forEach(d => { + const en = new Intl.DateTimeFormat("en", { timeStyle: d.time, timeZone: "UTC" }); + expect(en.formatRange(d0, d1)).toBe(d.en); + + const ja = new Intl.DateTimeFormat("ja", { timeStyle: d.time, timeZone: "UTC" }); + expect(ja.formatRange(d0, d1)).toBe(d.ja); + }); + }); +}); + +describe("dateStyle + timeStyle", () => { + // prettier-ignore + const data = [ + { date: "full", time: "full", en: "Monday, January 23, 1989 at 7:08:09 AM Coordinated Universal Time – Tuesday, December 7, 2021 at 5:40:50 PM Coordinated Universal Time", ja: "1989年1月23日月曜日 7時08分09秒 協定世界時~2021年12月7日火曜日 17時40分50秒 協定世界時" }, + { date: "full", time: "long", en: "Monday, January 23, 1989 at 7:08:09 AM UTC – Tuesday, December 7, 2021 at 5:40:50 PM UTC", ja: "1989年1月23日月曜日 7:08:09 UTC~2021年12月7日火曜日 17:40:50 UTC" }, + { date: "full", time: "medium", en: "Monday, January 23, 1989 at 7:08:09 AM – Tuesday, December 7, 2021 at 5:40:50 PM", ja: "1989年1月23日月曜日 7:08:09~2021年12月7日火曜日 17:40:50" }, + { date: "full", time: "short", en: "Monday, January 23, 1989 at 7:08 AM – Tuesday, December 7, 2021 at 5:40 PM", ja: "1989年1月23日月曜日 7:08~2021年12月7日火曜日 17:40" }, + { date: "long", time: "full", en: "January 23, 1989 at 7:08:09 AM Coordinated Universal Time – December 7, 2021 at 5:40:50 PM Coordinated Universal Time", ja: "1989年1月23日 7時08分09秒 協定世界時~2021年12月7日 17時40分50秒 協定世界時" }, + { date: "long", time: "long", en: "January 23, 1989 at 7:08:09 AM UTC – December 7, 2021 at 5:40:50 PM UTC", ja: "1989年1月23日 7:08:09 UTC~2021年12月7日 17:40:50 UTC" }, + { date: "long", time: "medium", en: "January 23, 1989 at 7:08:09 AM – December 7, 2021 at 5:40:50 PM", ja: "1989年1月23日 7:08:09~2021年12月7日 17:40:50" }, + { date: "long", time: "short", en: "January 23, 1989 at 7:08 AM – December 7, 2021 at 5:40 PM", ja: "1989年1月23日 7:08~2021年12月7日 17:40" }, + { date: "medium", time: "full", en: "Jan 23, 1989, 7:08:09 AM Coordinated Universal Time – Dec 7, 2021, 5:40:50 PM Coordinated Universal Time", ja: "1989/01/23 7時08分09秒 協定世界時~2021/12/07 17時40分50秒 協定世界時" }, + { date: "medium", time: "long", en: "Jan 23, 1989, 7:08:09 AM UTC – Dec 7, 2021, 5:40:50 PM UTC", ja: "1989/01/23 7:08:09 UTC~2021/12/07 17:40:50 UTC" }, + { date: "medium", time: "medium", en: "Jan 23, 1989, 7:08:09 AM – Dec 7, 2021, 5:40:50 PM", ja: "1989/01/23 7:08:09~2021/12/07 17:40:50" }, + { date: "medium", time: "short", en: "Jan 23, 1989, 7:08 AM – Dec 7, 2021, 5:40 PM", ja: "1989/01/23 7:08~2021/12/07 17:40" }, + { date: "short", time: "full", en: "1/23/89, 7:08:09 AM Coordinated Universal Time – 12/7/21, 5:40:50 PM Coordinated Universal Time", ja: "1989/01/23 7時08分09秒 協定世界時~2021/12/07 17時40分50秒 協定世界時" }, + { date: "short", time: "long", en: "1/23/89, 7:08:09 AM UTC – 12/7/21, 5:40:50 PM UTC", ja: "1989/01/23 7:08:09 UTC~2021/12/07 17:40:50 UTC" }, + { date: "short", time: "medium", en: "1/23/89, 7:08:09 AM – 12/7/21, 5:40:50 PM", ja: "1989/01/23 7:08:09~2021/12/07 17:40:50" }, + { date: "short", time: "short", en: "1/23/89, 7:08 AM – 12/7/21, 5:40 PM", ja: "1989/01/23 7:08~2021/12/07 17:40" }, + ]; + + test("all", () => { + data.forEach(d => { + const en = new Intl.DateTimeFormat("en", { + dateStyle: d.date, + timeStyle: d.time, + timeZone: "UTC", + }); + expect(en.formatRange(d0, d1)).toBe(d.en); + + const ja = new Intl.DateTimeFormat("ja", { + dateStyle: d.date, + timeStyle: d.time, + timeZone: "UTC", + }); + expect(ja.formatRange(d0, d1)).toBe(d.ja); + }); + }); +});