From b4a772cde232db5ad45b44e1862ae009497bbac7 Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Wed, 20 Jul 2022 15:08:01 -0400 Subject: [PATCH] LibJS: Implement Intl.NumberFormat.prototype.formatRange --- Userland/Libraries/LibJS/Runtime/ErrorTypes.h | 1 + .../LibJS/Runtime/Intl/NumberFormat.cpp | 144 ++++++++++++++++++ .../LibJS/Runtime/Intl/NumberFormat.h | 4 + .../Runtime/Intl/NumberFormatPrototype.cpp | 28 ++++ .../Runtime/Intl/NumberFormatPrototype.h | 1 + .../NumberFormat.prototype.formatRange.js | 140 +++++++++++++++++ 6 files changed, 318 insertions(+) create mode 100644 Userland/Libraries/LibJS/Tests/builtins/Intl/NumberFormat/NumberFormat.prototype.formatRange.js diff --git a/Userland/Libraries/LibJS/Runtime/ErrorTypes.h b/Userland/Libraries/LibJS/Runtime/ErrorTypes.h index af9a88bb27..2d3c25a86f 100644 --- a/Userland/Libraries/LibJS/Runtime/ErrorTypes.h +++ b/Userland/Libraries/LibJS/Runtime/ErrorTypes.h @@ -54,6 +54,7 @@ M(IntlNumberIsNaN, "{} must not be NaN") \ M(IntlNumberIsNaNOrInfinity, "Number must not be NaN or Infinity") \ M(IntlNumberIsNaNOrOutOfRange, "Value {} is NaN or is not between {} and {}") \ + M(IntlNumberRangeIsInvalid, "Numeric range is invalid: {}") \ M(IntlOptionUndefined, "Option {} must be defined when option {} is {}") \ M(IntlNonNumericOr2DigitAfterNumericOr2Digit, "Styles other than 'numeric' and '2-digit' may not be used in smaller units after " \ "being used in larger units") \ diff --git a/Userland/Libraries/LibJS/Runtime/Intl/NumberFormat.cpp b/Userland/Libraries/LibJS/Runtime/Intl/NumberFormat.cpp index 0e61c4d376..5e93aaf2ee 100644 --- a/Userland/Libraries/LibJS/Runtime/Intl/NumberFormat.cpp +++ b/Userland/Libraries/LibJS/Runtime/Intl/NumberFormat.cpp @@ -1718,4 +1718,148 @@ RoundingDecision apply_unsigned_rounding_mode(MathematicalValue const& x, Mathem return RoundingDecision::HigherValue; } +// 1.1.21 PartitionNumberRangePattern ( numberFormat, x, y ), https://tc39.es/proposal-intl-numberformat-v3/out/numberformat/proposed.html#sec-partitionnumberrangepattern +ThrowCompletionOr> partition_number_range_pattern(GlobalObject& global_object, NumberFormat& number_format, MathematicalValue start, MathematicalValue end) +{ + auto& vm = global_object.vm(); + + // 1. If x is NaN or y is NaN, throw a RangeError exception. + if (start.is_nan()) + return vm.throw_completion(global_object, ErrorType::IntlNumberIsNaN, "start"sv); + if (end.is_nan()) + return vm.throw_completion(global_object, ErrorType::IntlNumberIsNaN, "end"sv); + + // 2. If x is a mathematical value, then + if (start.is_mathematical_value()) { + // a. If y is a mathematical value and y < x, throw a RangeError exception. + if (end.is_mathematical_value() && end.is_less_than(start)) + return vm.throw_completion(global_object, ErrorType::IntlNumberRangeIsInvalid, "start is a mathematical value, end is a mathematical value and end < start"sv); + + // b. Else if y is -∞, throw a RangeError exception. + if (end.is_negative_infinity()) + return vm.throw_completion(global_object, ErrorType::IntlNumberRangeIsInvalid, "start is a mathematical value, end is -∞"sv); + + // c. Else if y is -0𝔽 and x ≥ 0, throw a RangeError exception. + if (end.is_negative_zero() && (start.is_zero() || start.is_positive())) + return vm.throw_completion(global_object, ErrorType::IntlNumberRangeIsInvalid, "start is a mathematical value, end is -0 and start ≥ 0"sv); + } + // 3. Else if x is +∞, then + else if (start.is_positive_infinity()) { + // a. If y is a mathematical value, throw a RangeError exception. + if (end.is_mathematical_value()) + return vm.throw_completion(global_object, ErrorType::IntlNumberRangeIsInvalid, "start is +∞, end is a mathematical value"sv); + + // b. Else if y is -∞, throw a RangeError exception. + if (end.is_negative_infinity()) + return vm.throw_completion(global_object, ErrorType::IntlNumberRangeIsInvalid, "start is +∞, end is -∞"sv); + + // c. Else if y is -0𝔽, throw a RangeError exception. + if (end.is_negative_zero()) + return vm.throw_completion(global_object, ErrorType::IntlNumberRangeIsInvalid, "start is +∞, end is -0"sv); + } + // 4. Else if x is -0𝔽, then + else if (start.is_negative_zero()) { + // a. If y is a mathematical value and y < 0, throw a RangeError exception. + if (end.is_mathematical_value() && end.is_negative()) + return vm.throw_completion(global_object, ErrorType::IntlNumberRangeIsInvalid, "start is -0, end is a mathematical value and end < 0"sv); + + // b. Else if y is -∞, throw a RangeError exception. + if (end.is_negative_infinity()) + return vm.throw_completion(global_object, ErrorType::IntlNumberRangeIsInvalid, "start is -0, end is -∞"sv); + } + + // 5. Let result be a new empty List. + Vector result; + + // 6. Let xResult be ? PartitionNumberPattern(numberFormat, x). + auto raw_start_result = partition_number_pattern(global_object, number_format, move(start)); + auto start_result = PatternPartitionWithSource::create_from_parent_list(move(raw_start_result)); + + // 7. Let yResult be ? PartitionNumberPattern(numberFormat, y). + auto raw_end_result = partition_number_pattern(global_object, number_format, move(end)); + auto end_result = PatternPartitionWithSource::create_from_parent_list(move(raw_end_result)); + + // 8. If xResult is equal to yResult, return FormatApproximately(numberFormat, xResult). + if (start_result == end_result) + return format_approximately(number_format, move(start_result)); + + // 9. For each r in xResult, do + for (auto& part : start_result) { + // i. Set r.[[Source]] to "startRange". + part.source = "startRange"sv; + } + + // 10. Add all elements in xResult to result in order. + result = move(start_result); + + // 11. Let rangeSeparator be an ILND String value used to separate two numbers. + auto range_separator_symbol = Unicode::get_number_system_symbol(number_format.data_locale(), number_format.numbering_system(), Unicode::NumericSymbol::RangeSeparator).value_or("-"sv); + auto range_separator = Unicode::augment_range_pattern(range_separator_symbol, result.last().value, end_result[0].value); + + // 12. Append a new Record { [[Type]]: "literal", [[Value]]: rangeSeparator, [[Source]]: "shared" } element to result. + PatternPartitionWithSource part; + part.type = "literal"sv; + part.value = range_separator.value_or(range_separator_symbol); + part.source = "shared"sv; + result.append(move(part)); + + // 13. For each r in yResult, do + for (auto& part : end_result) { + // a. Set r.[[Source]] to "endRange". + part.source = "endRange"sv; + } + + // 14. Add all elements in yResult to result in order. + result.extend(move(end_result)); + + // 15. Return ! CollapseNumberRange(result). + return collapse_number_range(move(result)); +} + +// 1.1.22 FormatApproximately ( numberFormat, result ), https://tc39.es/proposal-intl-numberformat-v3/out/numberformat/proposed.html#sec-formatapproximately +Vector format_approximately(NumberFormat& number_format, Vector result) +{ + // 1. Let i be an index into result, determined by an implementation-defined algorithm based on numberFormat and result. + // 2. Let approximatelySign be an ILND String value used to signify that a number is approximate. + auto approximately_sign = Unicode::get_number_system_symbol(number_format.data_locale(), number_format.numbering_system(), Unicode::NumericSymbol::ApproximatelySign).value_or("~"sv); + + // 3. Insert a new Record { [[Type]]: "approximatelySign", [[Value]]: approximatelySign } at index i in result. + PatternPartitionWithSource partition; + partition.type = "approximatelySign"sv; + partition.value = approximately_sign; + + result.insert_before_matching(move(partition), [](auto const& part) { + return part.type.is_one_of("integer"sv, "decimal"sv, "plusSign"sv, "minusSign"sv, "percentSign"sv, "currency"sv); + }); + + // 4. Return result. + return result; +} + +// 1.1.23 CollapseNumberRange ( result ), https://tc39.es/proposal-intl-numberformat-v3/out/numberformat/proposed.html#sec-collapsenumberrange +Vector collapse_number_range(Vector result) +{ + // Returning result unmodified is guaranteed to be a correct implementation of CollapseNumberRange. + return result; +} + +// 1.1.24 FormatNumericRange( numberFormat, x, y ), https://tc39.es/proposal-intl-numberformat-v3/out/numberformat/proposed.html#sec-formatnumericrange +ThrowCompletionOr format_numeric_range(GlobalObject& global_object, NumberFormat& number_format, MathematicalValue start, MathematicalValue end) +{ + // 1. Let parts be ? PartitionNumberRangePattern(numberFormat, x, y). + auto parts = TRY(partition_number_range_pattern(global_object, number_format, move(start), move(end))); + + // 2. Let result be the empty String. + StringBuilder result; + + // 3. For each 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(); +} + } diff --git a/Userland/Libraries/LibJS/Runtime/Intl/NumberFormat.h b/Userland/Libraries/LibJS/Runtime/Intl/NumberFormat.h index 2cbaa25541..2e235c41f8 100644 --- a/Userland/Libraries/LibJS/Runtime/Intl/NumberFormat.h +++ b/Userland/Libraries/LibJS/Runtime/Intl/NumberFormat.h @@ -287,5 +287,9 @@ int compute_exponent_for_magnitude(NumberFormat& number_format, int magnitude); ThrowCompletionOr to_intl_mathematical_value(GlobalObject& global_object, Value value); NumberFormat::UnsignedRoundingMode get_unsigned_rounding_mode(NumberFormat::RoundingMode rounding_mode, bool is_negative); RoundingDecision apply_unsigned_rounding_mode(MathematicalValue const& x, MathematicalValue const& r1, MathematicalValue const& r2, Optional const& unsigned_rounding_mode); +ThrowCompletionOr> partition_number_range_pattern(GlobalObject& global_object, NumberFormat& number_format, MathematicalValue start, MathematicalValue end); +Vector format_approximately(NumberFormat& number_format, Vector result); +Vector collapse_number_range(Vector result); +ThrowCompletionOr format_numeric_range(GlobalObject& global_object, NumberFormat& number_format, MathematicalValue start, MathematicalValue end); } diff --git a/Userland/Libraries/LibJS/Runtime/Intl/NumberFormatPrototype.cpp b/Userland/Libraries/LibJS/Runtime/Intl/NumberFormatPrototype.cpp index baa65f59e5..b6897522c1 100644 --- a/Userland/Libraries/LibJS/Runtime/Intl/NumberFormatPrototype.cpp +++ b/Userland/Libraries/LibJS/Runtime/Intl/NumberFormatPrototype.cpp @@ -32,6 +32,7 @@ void NumberFormatPrototype::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); } @@ -76,6 +77,33 @@ JS_DEFINE_NATIVE_FUNCTION(NumberFormatPrototype::format_to_parts) return format_numeric_to_parts(global_object, *number_format, move(mathematical_value)); } +// 1.4.5 Intl.NumberFormat.prototype.formatRange ( start, end ), https://tc39.es/proposal-intl-numberformat-v3/out/numberformat/proposed.html#sec-intl.numberformat.prototype.formatrange +JS_DEFINE_NATIVE_FUNCTION(NumberFormatPrototype::format_range) +{ + auto start = vm.argument(0); + auto end = vm.argument(1); + + // 1. Let nf be the this value. + // 2. Perform ? RequireInternalSlot(nf, [[InitializedNumberFormat]]). + auto* number_format = TRY(typed_this_object(global_object)); + + // 3. If start is undefined or end is undefined, throw a TypeError exception. + if (start.is_undefined()) + return vm.throw_completion(global_object, ErrorType::IsUndefined, "start"sv); + if (end.is_undefined()) + return vm.throw_completion(global_object, ErrorType::IsUndefined, "end"sv); + + // 4. Let x be ? ToIntlMathematicalValue(start). + auto x = TRY(to_intl_mathematical_value(global_object, start)); + + // 5. Let y be ? ToIntlMathematicalValue(end). + auto y = TRY(to_intl_mathematical_value(global_object, end)); + + // 6. Return ? FormatNumericRange(nf, x, y). + auto formatted = TRY(format_numeric_range(global_object, *number_format, move(x), move(y))); + return js_string(vm, move(formatted)); +} + // 15.3.5 Intl.NumberFormat.prototype.resolvedOptions ( ), https://tc39.es/ecma402/#sec-intl.numberformat.prototype.resolvedoptions JS_DEFINE_NATIVE_FUNCTION(NumberFormatPrototype::resolved_options) { diff --git a/Userland/Libraries/LibJS/Runtime/Intl/NumberFormatPrototype.h b/Userland/Libraries/LibJS/Runtime/Intl/NumberFormatPrototype.h index 9cee03c442..9528038a62 100644 --- a/Userland/Libraries/LibJS/Runtime/Intl/NumberFormatPrototype.h +++ b/Userland/Libraries/LibJS/Runtime/Intl/NumberFormatPrototype.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/NumberFormat/NumberFormat.prototype.formatRange.js b/Userland/Libraries/LibJS/Tests/builtins/Intl/NumberFormat/NumberFormat.prototype.formatRange.js new file mode 100644 index 0000000000..d6fd3438bf --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/builtins/Intl/NumberFormat/NumberFormat.prototype.formatRange.js @@ -0,0 +1,140 @@ +describe("errors", () => { + test("called on non-NumberFormat object", () => { + expect(() => { + Intl.NumberFormat.prototype.formatRange(); + }).toThrowWithMessage(TypeError, "Not an object of type Intl.NumberFormat"); + }); + + test("called without enough values", () => { + expect(() => { + new Intl.NumberFormat().formatRange(); + }).toThrowWithMessage(TypeError, "start is undefined"); + + expect(() => { + new Intl.NumberFormat().formatRange(1); + }).toThrowWithMessage(TypeError, "end is undefined"); + }); + + test("called with values that cannot be converted to numbers", () => { + expect(() => { + new Intl.NumberFormat().formatRange(Symbol.hasInstance, 1); + }).toThrowWithMessage(TypeError, "Cannot convert symbol to number"); + + expect(() => { + new Intl.NumberFormat().formatRange(1, Symbol.hasInstance); + }).toThrowWithMessage(TypeError, "Cannot convert symbol to number"); + }); + + test("called with invalid numbers", () => { + expect(() => { + new Intl.NumberFormat().formatRange(NaN, 1); + }).toThrowWithMessage(RangeError, "start must not be NaN"); + + expect(() => { + new Intl.NumberFormat().formatRange(1, NaN); + }).toThrowWithMessage(RangeError, "end must not be NaN"); + + expect(() => { + new Intl.NumberFormat().formatRange(1, 0); + }).toThrowWithMessage( + RangeError, + "start is a mathematical value, end is a mathematical value and end < start" + ); + + expect(() => { + new Intl.NumberFormat().formatRange(1, -Infinity); + }).toThrowWithMessage(RangeError, "start is a mathematical value, end is -∞"); + + expect(() => { + new Intl.NumberFormat().formatRange(1, -0); + }).toThrowWithMessage(RangeError, "start is a mathematical value, end is -0 and start ≥ 0"); + + expect(() => { + new Intl.NumberFormat().formatRange(Infinity, 0); + }).toThrowWithMessage(RangeError, "start is +∞, end is a mathematical value"); + + expect(() => { + new Intl.NumberFormat().formatRange(Infinity, -Infinity); + }).toThrowWithMessage(RangeError, "start is +∞, end is -∞"); + + expect(() => { + new Intl.NumberFormat().formatRange(Infinity, -0); + }).toThrowWithMessage(RangeError, "start is +∞, end is -0"); + + expect(() => { + new Intl.NumberFormat().formatRange(-0, -1); + }).toThrowWithMessage(RangeError, "start is -0, end is a mathematical value and end < 0"); + + expect(() => { + new Intl.NumberFormat().formatRange(-0, -Infinity); + }).toThrowWithMessage(RangeError, "start is -0, end is -∞"); + }); +}); + +describe("correct behavior", () => { + test("basic functionality", () => { + const en1 = new Intl.NumberFormat("en"); + expect(en1.formatRange(100, 101)).toBe("100–101"); + expect(en1.formatRange(3.14, 6.28)).toBe("3.14–6.28"); + expect(en1.formatRange(-0, 1)).toBe("-0–1"); + + const ja1 = new Intl.NumberFormat("ja"); + expect(ja1.formatRange(100, 101)).toBe("100~101"); + expect(ja1.formatRange(3.14, 6.28)).toBe("3.14~6.28"); + expect(ja1.formatRange(-0, 1)).toBe("-0~1"); + }); + + test("approximately formatting", () => { + const en1 = new Intl.NumberFormat("en", { maximumFractionDigits: 0 }); + expect(en1.formatRange(2.9, 3.1)).toBe("~3"); + expect(en1.formatRange(-3.1, -2.9)).toBe("~-3"); + + const en2 = new Intl.NumberFormat("en", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }); + expect(en2.formatRange(2.9, 3.1)).toBe("~$3"); + expect(en2.formatRange(-3.1, -2.9)).toBe("~-$3"); + + const ja1 = new Intl.NumberFormat("ja", { maximumFractionDigits: 0 }); + expect(ja1.formatRange(2.9, 3.1)).toBe("約3"); + expect(ja1.formatRange(-3.1, -2.9)).toBe("約-3"); + + const ja2 = new Intl.NumberFormat("ja", { + style: "currency", + currency: "JPY", + maximumFractionDigits: 0, + }); + expect(ja2.formatRange(2.9, 3.1)).toBe("約¥3"); + expect(ja2.formatRange(-3.1, -2.9)).toBe("約-¥3"); + }); + + test("range pattern spacing", () => { + const en1 = new Intl.NumberFormat("en"); + expect(en1.formatRange(3, 5)).toBe("3–5"); + expect(en1.formatRange(-1, -0)).toBe("-1 – -0"); + expect(en1.formatRange(0, Infinity)).toBe("0 – ∞"); + expect(en1.formatRange(-Infinity, 0)).toBe("-∞ – 0"); + + const en2 = new Intl.NumberFormat("en", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }); + expect(en2.formatRange(3, 5)).toBe("$3 – $5"); + + const ja1 = new Intl.NumberFormat("ja"); + expect(ja1.formatRange(3, 5)).toBe("3~5"); + expect(ja1.formatRange(-1, -0)).toBe("-1 ~ -0"); + expect(ja1.formatRange(0, Infinity)).toBe("0 ~ ∞"); + expect(ja1.formatRange(-Infinity, 0)).toBe("-∞ ~ 0"); + + const ja2 = new Intl.NumberFormat("ja", { + style: "currency", + currency: "JPY", + maximumFractionDigits: 0, + }); + expect(ja2.formatRange(3, 5)).toBe("¥3 ~ ¥5"); + }); +});