diff --git a/docs/built-ins.md b/docs/built-ins.md index 9bcc9ec9..64fbe9aa 100644 --- a/docs/built-ins.md +++ b/docs/built-ins.md @@ -83,7 +83,7 @@ Implements the [ECMAScript Object](https://developer.mozilla.org/en-US/docs/Web/ ### Array (`Goccia.Builtins.GlobalArray.pas`) -Implements the [ECMAScript Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array). **Not implemented:** `Array.prototype.reduceRight`, `Array.prototype.toLocaleString`. +Implements the [ECMAScript Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array). **Not implemented:** `Array.prototype.reduceRight`. **Static methods:** @@ -115,6 +115,7 @@ Implements the [ECMAScript Array](https://developer.mozilla.org/en-US/docs/Web/J | `includes(value, fromIndex?)` | Check if array contains value | | `join(separator?)` | Join elements into string | | `toString()` | Returns comma-separated string (delegates to `join()`) | +| `toLocaleString(locales?, options?)` | Join element locale strings | | `concat(...arrays)` | Concatenate arrays | | `slice(start?, end?)` | Extract a section | | `push(...items)` | Add to end (mutating) | diff --git a/source/units/Goccia.Shims.pas b/source/units/Goccia.Shims.pas index ee58aad4..c6856226 100644 --- a/source/units/Goccia.Shims.pas +++ b/source/units/Goccia.Shims.pas @@ -180,7 +180,38 @@ implementation ' return Math.trunc(epochMilliseconds);'#10 + ' } catch (e) { return NaN; }'#10 + '};'#10 + + 'const __GocciaShimNumberFormat: any = Intl.NumberFormat;'#10 + + 'const __GocciaShimDateTimeFormat: any = Intl.DateTimeFormat;'#10 + 'export const Date = class Date {'#10 + + ' static {'#10 + + ' const numberLocale = {'#10 + + ' toLocaleString(...args: any[]): string {'#10 + + ' const value: number = Number.prototype.valueOf.call(this);'#10 + + ' return new __GocciaShimNumberFormat(args[0], args[1]).format(value);'#10 + + ' }'#10 + + ' }.toLocaleString;'#10 + + ' const arrayLocale = {'#10 + + ' toLocaleString(...args: any[]): string {'#10 + + ' const array: any = Object(this);'#10 + + ' const length: number = Number(array.length);'#10 + + ' const len: number = !Number.isFinite(length) || length <= 0 ? 0 : Math.trunc(length);'#10 + + ' const elementString = (index: number): string => {'#10 + + ' const nextElement: any = array[index];'#10 + + ' if (nextElement !== undefined && nextElement !== null) {'#10 + + ' const method: any = Object(nextElement).toLocaleString;'#10 + + ' if (typeof method !== "function")'#10 + + ' throw new TypeError("Array.prototype.toLocaleString element toLocaleString is not a function");'#10 + + ' return String(method.call(nextElement, args[0], args[1]));'#10 + + ' }'#10 + + ' return "";'#10 + + ' };'#10 + + ' const build = (index: number, result: string): string => index >= len ? result : build(index + 1, result + (index > 0 ? "," : "") + elementString(index));'#10 + + ' return build(0, "");'#10 + + ' }'#10 + + ' }.toLocaleString;'#10 + + ' Object.defineProperty(Number.prototype, "toLocaleString", { value: numberLocale, writable: true, configurable: true });'#10 + + ' Object.defineProperty(Array.prototype, "toLocaleString", { value: arrayLocale, writable: true, configurable: true });'#10 + + ' }'#10 + ' #ms;'#10 + ' static now(): number { return Temporal.Now.instant().epochMilliseconds; }'#10 + ' static parse(str: string): number {'#10 + @@ -244,6 +275,41 @@ 'export const Date = class Date {'#10 + ' return days[z.dayOfWeek % 7] + " " + months[z.month - 1] + " " + pad(z.day) + " " +'#10 + ' String(z.year) + " " + pad(z.hour) + ":" + pad(z.minute) + ":" + pad(z.second) + " GMT" + z.offset;'#10 + ' }'#10 + + ' #localeOptions(options: any, needDate: boolean, needTime: boolean): any {'#10 + + ' const out: any = {};'#10 + + ' if (options !== undefined) {'#10 + + ' ["localeMatcher", "calendar", "numberingSystem", "timeZone", "hour12", "hourCycle", "formatMatcher",'#10 + + ' "weekday", "era", "year", "month", "day", "dayPeriod", "hour", "minute", "second",'#10 + + ' "fractionalSecondDigits", "timeZoneName", "dateStyle", "timeStyle"].forEach((key: string): void => {'#10 + + ' const value: any = options[key];'#10 + + ' if (value !== undefined) out[key] = value;'#10 + + ' });'#10 + + ' }'#10 + + ' const hasDate = out.weekday !== undefined || out.year !== undefined || out.month !== undefined || out.day !== undefined || out.dateStyle !== undefined;'#10 + + ' const hasTime = out.dayPeriod !== undefined || out.hour !== undefined || out.minute !== undefined || out.second !== undefined || out.fractionalSecondDigits !== undefined || out.timeStyle !== undefined;'#10 + + ' const needDefaults = !hasDate && !hasTime;'#10 + + ' if (needDefaults && needDate) { out.year = "numeric"; out.month = "numeric"; out.day = "numeric"; }'#10 + + ' if (needDefaults && needTime) { out.hour = "numeric"; out.minute = "numeric"; out.second = "numeric"; }'#10 + + ' return out;'#10 + + ' }'#10 + + ' toLocaleString(...args: any[]): string {'#10 + + ' let valid: boolean;'#10 + + ' try { valid = this.#valid(); } catch (e) { throw new TypeError("Date.prototype.toLocaleString called on non-Date"); }'#10 + + ' if (!valid) return "Invalid Date";'#10 + + ' return new __GocciaShimDateTimeFormat(args[0], this.#localeOptions(args[1], true, true)).format(this.#ms);'#10 + + ' }'#10 + + ' toLocaleDateString(...args: any[]): string {'#10 + + ' let valid: boolean;'#10 + + ' try { valid = this.#valid(); } catch (e) { throw new TypeError("Date.prototype.toLocaleDateString called on non-Date"); }'#10 + + ' if (!valid) return "Invalid Date";'#10 + + ' return new __GocciaShimDateTimeFormat(args[0], this.#localeOptions(args[1], true, false)).format(this.#ms);'#10 + + ' }'#10 + + ' toLocaleTimeString(...args: any[]): string {'#10 + + ' let valid: boolean;'#10 + + ' try { valid = this.#valid(); } catch (e) { throw new TypeError("Date.prototype.toLocaleTimeString called on non-Date"); }'#10 + + ' if (!valid) return "Invalid Date";'#10 + + ' return new __GocciaShimDateTimeFormat(args[0], this.#localeOptions(args[1], false, true)).format(this.#ms);'#10 + + ' }'#10 + '};' ), ( // ES2026 §20.1.3.2 — legacy hasOwnProperty via Object.hasOwn diff --git a/source/units/Goccia.Values.TypedArrayValue.pas b/source/units/Goccia.Values.TypedArrayValue.pas index 9ae89f59..4f2c11c6 100644 --- a/source/units/Goccia.Values.TypedArrayValue.pas +++ b/source/units/Goccia.Values.TypedArrayValue.pas @@ -116,6 +116,7 @@ TGocciaTypedArrayValue = class(TGocciaInstanceValue) function TypedArrayReduce(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function TypedArrayReduceRight(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function TypedArrayJoin(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; + function TypedArrayToLocaleString(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function TypedArrayToString(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function TypedArrayToReversed(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function TypedArrayToSorted(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; @@ -846,6 +847,7 @@ procedure TGocciaTypedArrayValue.InitializePrototype; Members.AddMethod(TypedArrayReduce, 2, gmkPrototypeMethod, [gmfNoFunctionPrototype]); Members.AddMethod(TypedArrayReduceRight, 2, gmkPrototypeMethod, [gmfNoFunctionPrototype]); Members.AddMethod(TypedArrayJoin, 1, gmkPrototypeMethod, [gmfNoFunctionPrototype]); + Members.AddNamedMethod(PROP_TO_LOCALE_STRING, TypedArrayToLocaleString, 0, gmkPrototypeMethod, [gmfNoFunctionPrototype]); Members.AddMethod(TypedArrayToString, 0, gmkPrototypeMethod, [gmfNoFunctionPrototype]); Members.AddMethod(TypedArrayToReversed, 0, gmkPrototypeMethod, [gmfNoFunctionPrototype]); Members.AddMethod(TypedArrayToSorted, 1, gmkPrototypeMethod, [gmfNoFunctionPrototype]); @@ -2129,6 +2131,48 @@ function TGocciaTypedArrayValue.TypedArrayJoin(const AArgs: TGocciaArgumentsColl Result := TGocciaStringLiteralValue.Create(S); end; +// ECMA-402 §17.1.6 %TypedArray%.prototype.toLocaleString([locales [, options]]) +function TGocciaTypedArrayValue.TypedArrayToLocaleString(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +var + TA: TGocciaTypedArrayValue; + I, Len: Integer; + S: string; + Element, Method, Formatted: TGocciaValue; + ElementObject: TGocciaObjectValue; + CallArgs: TGocciaArgumentsCollection; +begin + TA := RequireAttachedTypedArray(AThisValue, '%TypedArray%.prototype.toLocaleString'); + Len := TA.FLength; + S := ''; + for I := 0 to Len - 1 do + begin + if I > 0 then S := S + ','; + Element := TA.GetElementAsValue(I); + ElementObject := ToObject(Element); + Method := ElementObject.GetPropertyWithContext(PROP_TO_LOCALE_STRING, Element); + if (Method = nil) or not Method.IsCallable then + ThrowTypeError('TypedArray.prototype.toLocaleString element toLocaleString is not a function'); + + CallArgs := TGocciaArgumentsCollection.CreateWithCapacity(2); + try + if AArgs.Length > 0 then + CallArgs.Add(AArgs.GetElement(0)) + else + CallArgs.Add(TGocciaUndefinedLiteralValue.UndefinedValue); + if AArgs.Length > 1 then + CallArgs.Add(AArgs.GetElement(1)) + else + CallArgs.Add(TGocciaUndefinedLiteralValue.UndefinedValue); + Formatted := InvokeCallable(Method, CallArgs, Element); + finally + CallArgs.Free; + end; + S := S + Formatted.ToStringLiteral.Value; + end; + Result := TGocciaStringLiteralValue.Create(S); +end; + // ES2026 §23.2.3.33 %TypedArray%.prototype.toString() function TGocciaTypedArrayValue.TypedArrayToString(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; begin diff --git a/tests/built-ins/Array/prototype/toLocaleString.js b/tests/built-ins/Array/prototype/toLocaleString.js new file mode 100644 index 00000000..e524aa03 --- /dev/null +++ b/tests/built-ins/Array/prototype/toLocaleString.js @@ -0,0 +1,58 @@ +/*--- +description: Array.prototype.toLocaleString delegates to each element +features: [Intl, Array.prototype.toLocaleString] +---*/ + +const isIntl = typeof Intl !== "undefined"; + +describe("Array.prototype.toLocaleString", () => { + test("exists on Array.prototype", () => { + expect(typeof Array.prototype.toLocaleString).toBe("function"); + }); + + test("invokes each element toLocaleString with locales and options", () => { + const calls = []; + const options = { style: "currency", currency: "EUR" }; + const item = { + toLocaleString(locales, receivedOptions) { + calls.push([locales, receivedOptions]); + return "item"; + }, + }; + + expect([item, null, undefined, item].toLocaleString("de-DE", options)).toBe("item,,,item"); + expect(calls.length).toBe(2); + expect(calls[0][0]).toBe("de-DE"); + expect(calls[0][1]).toBe(options); + expect(calls[1][0]).toBe("de-DE"); + expect(calls[1][1]).toBe(options); + }); + + test("throws when an element toLocaleString is not callable", () => { + expect(() => [{ toLocaleString: 1 }].toLocaleString()).toThrow(TypeError); + }); + + test("does not delegate to mutable Array helpers", () => { + const originalFrom = Array.from; + const originalJoin = Array.prototype.join; + + try { + Array.from = () => ["tainted"]; + Array.prototype.join = () => "tainted"; + + expect([1, 2].toLocaleString()).toBe("1,2"); + } finally { + Array.from = originalFrom; + Array.prototype.join = originalJoin; + } + }); +}); + +describe.runIf(isIntl)("Array.prototype.toLocaleString Intl elements", () => { + test("formats number elements through Number.prototype.toLocaleString", () => { + const options = { style: "currency", currency: "EUR" }; + const expected = new Intl.NumberFormat("de-DE", options).format(1234.5); + + expect([1234.5].toLocaleString("de-DE", options)).toBe(expected); + }); +}); diff --git a/tests/built-ins/Date/prototype/toLocaleDateString.js b/tests/built-ins/Date/prototype/toLocaleDateString.js new file mode 100644 index 00000000..9e6aa2de --- /dev/null +++ b/tests/built-ins/Date/prototype/toLocaleDateString.js @@ -0,0 +1,33 @@ +/*--- +description: Date.prototype.toLocaleDateString delegates to Intl.DateTimeFormat date defaults +features: [Intl, Date] +---*/ + +const isIntl = typeof Intl !== "undefined"; + +describe.runIf(isIntl)("Date.prototype.toLocaleDateString", () => { + test("formats with date defaults", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC" }; + + expect(date.toLocaleDateString("de-DE", options)).toBe(new Intl.DateTimeFormat("de-DE", { + timeZone: "UTC", + year: "numeric", + month: "numeric", + day: "numeric", + }).format(date)); + }); + + test("preserves explicit date options", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC", year: "numeric", month: "long" }; + + expect(date.toLocaleDateString("en-US", options)).toBe(new Intl.DateTimeFormat("en-US", options).format(date)); + }); + + test("throws TypeError for fake Date receiver", () => { + const fakeDate = Object.create(Date.prototype); + + expect(() => Date.prototype.toLocaleDateString.call(fakeDate)).toThrow(TypeError); + }); +}); diff --git a/tests/built-ins/Date/prototype/toLocaleString.js b/tests/built-ins/Date/prototype/toLocaleString.js new file mode 100644 index 00000000..a2168284 --- /dev/null +++ b/tests/built-ins/Date/prototype/toLocaleString.js @@ -0,0 +1,43 @@ +/*--- +description: Date.prototype.toLocaleString delegates to Intl.DateTimeFormat date and time defaults +features: [Intl, Date] +---*/ + +const isIntl = typeof Intl !== "undefined"; + +describe.runIf(isIntl)("Date.prototype.toLocaleString", () => { + test("formats with date and time defaults", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC" }; + + expect(date.toLocaleString("de-DE", options)).toBe(new Intl.DateTimeFormat("de-DE", { + timeZone: "UTC", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + }).format(date)); + }); + + test("preserves explicit date and time options", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" }; + + expect(date.toLocaleString("en-US", options)).toBe(new Intl.DateTimeFormat("en-US", options).format(date)); + }); + + test("preserves style-only options without adding component defaults", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC", dateStyle: "short" }; + + expect(date.toLocaleString("en-US", options)).toBe(new Intl.DateTimeFormat("en-US", options).format(date)); + }); + + test("throws TypeError for fake Date receiver", () => { + const fakeDate = Object.create(Date.prototype); + + expect(() => Date.prototype.toLocaleString.call(fakeDate)).toThrow(TypeError); + }); +}); diff --git a/tests/built-ins/Date/prototype/toLocaleTimeString.js b/tests/built-ins/Date/prototype/toLocaleTimeString.js new file mode 100644 index 00000000..151605c5 --- /dev/null +++ b/tests/built-ins/Date/prototype/toLocaleTimeString.js @@ -0,0 +1,33 @@ +/*--- +description: Date.prototype.toLocaleTimeString delegates to Intl.DateTimeFormat time defaults +features: [Intl, Date] +---*/ + +const isIntl = typeof Intl !== "undefined"; + +describe.runIf(isIntl)("Date.prototype.toLocaleTimeString", () => { + test("formats with time defaults", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC" }; + + expect(date.toLocaleTimeString("de-DE", options)).toBe(new Intl.DateTimeFormat("de-DE", { + timeZone: "UTC", + hour: "numeric", + minute: "numeric", + second: "numeric", + }).format(date)); + }); + + test("preserves explicit time options", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC", hour: "2-digit", minute: "2-digit" }; + + expect(date.toLocaleTimeString("en-US", options)).toBe(new Intl.DateTimeFormat("en-US", options).format(date)); + }); + + test("throws TypeError for fake Date receiver", () => { + const fakeDate = Object.create(Date.prototype); + + expect(() => Date.prototype.toLocaleTimeString.call(fakeDate)).toThrow(TypeError); + }); +}); diff --git a/tests/built-ins/Number/prototype/toLocaleString.js b/tests/built-ins/Number/prototype/toLocaleString.js new file mode 100644 index 00000000..3e128d51 --- /dev/null +++ b/tests/built-ins/Number/prototype/toLocaleString.js @@ -0,0 +1,28 @@ +/*--- +description: Number.prototype.toLocaleString delegates to Intl.NumberFormat +features: [Intl, Number.prototype.toLocaleString] +---*/ + +const isIntl = typeof Intl !== "undefined"; + +describe.runIf(isIntl)("Number.prototype.toLocaleString", () => { + test("formats with the requested locale", () => { + const value = 1234567.89; + + expect(value.toLocaleString("de-DE")).toBe(new Intl.NumberFormat("de-DE").format(value)); + expect(value.toLocaleString("en-US")).toBe(new Intl.NumberFormat("en-US").format(value)); + }); + + test("passes formatting options to Intl.NumberFormat", () => { + const options = { style: "currency", currency: "EUR" }; + const value = 1234.5; + + expect(value.toLocaleString("de-DE", options)).toBe(new Intl.NumberFormat("de-DE", options).format(value)); + }); + + test("works with Number objects", () => { + const value = new Number(42); + + expect(value.toLocaleString("en-US")).toBe(new Intl.NumberFormat("en-US").format(42)); + }); +}); diff --git a/tests/built-ins/TypedArray/prototype/toLocaleString.js b/tests/built-ins/TypedArray/prototype/toLocaleString.js new file mode 100644 index 00000000..ec1b55db --- /dev/null +++ b/tests/built-ins/TypedArray/prototype/toLocaleString.js @@ -0,0 +1,34 @@ +/*--- +description: TypedArray.prototype.toLocaleString delegates to each element +features: [Intl, TypedArray.prototype.toLocaleString] +---*/ + +const isIntl = typeof Intl !== "undefined"; + +describe("%TypedArray%.prototype.toLocaleString", () => { + test("invokes number element toLocaleString with locales and options", () => { + const original = Number.prototype.toLocaleString; + const options = { marker: "yes" }; + + try { + Number.prototype.toLocaleString = { + method(locales, receivedOptions) { + return "number:" + locales + ":" + receivedOptions.marker; + }, + }.method; + + expect(new Uint8Array([0]).toLocaleString("xx", options)).toBe("number:xx:yes"); + } finally { + Number.prototype.toLocaleString = original; + } + }); +}); + +describe.runIf(isIntl)("%TypedArray%.prototype.toLocaleString Intl elements", () => { + test("formats number elements through Number.prototype.toLocaleString", () => { + const options = { minimumFractionDigits: 3 }; + const expected = new Intl.NumberFormat("th-u-nu-thai", options).format(0); + + expect(new Uint8Array([0]).toLocaleString("th-u-nu-thai", options)).toBe(expected); + }); +});