From aa95fcbad3f61c52018b13dde4f99994082679b2 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 9 Apr 2024 09:38:16 -0700 Subject: [PATCH] Add numeric data structure (#3115) Adding new `Numeric` data structure for the value world. This PR doesn't export it so it won't be usable just yet but this sparate this quite complex entity from the rest of the value world PR --- .../feature-numeric-2024-3-4-23-14-45.md | 8 + packages/compiler/src/core/numeric.ts | 195 +++++++++++++ packages/compiler/test/core/numeric.test.ts | 275 ++++++++++++++++++ tsconfig.base.json | 4 +- 4 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 .chronus/changes/feature-numeric-2024-3-4-23-14-45.md create mode 100644 packages/compiler/src/core/numeric.ts create mode 100644 packages/compiler/test/core/numeric.test.ts diff --git a/.chronus/changes/feature-numeric-2024-3-4-23-14-45.md b/.chronus/changes/feature-numeric-2024-3-4-23-14-45.md new file mode 100644 index 0000000000..840bf8d06e --- /dev/null +++ b/.chronus/changes/feature-numeric-2024-3-4-23-14-45.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: internal +packages: + - "@typespec/compiler" +--- + +Add new `Numeric` data structure diff --git a/packages/compiler/src/core/numeric.ts b/packages/compiler/src/core/numeric.ts new file mode 100644 index 0000000000..28a455aa74 --- /dev/null +++ b/packages/compiler/src/core/numeric.ts @@ -0,0 +1,195 @@ +export interface Numeric { + /** + * Return the value as JavaScript number or null if it cannot be represented without loosing precision. + */ + asNumber(): number | null; + asBigInt(): bigint | null; + toString(): string; + equals(value: Numeric): boolean; + gt(value: Numeric): boolean; + lt(value: Numeric): boolean; + gte(value: Numeric): boolean; + lte(value: Numeric): boolean; + + readonly isInteger: boolean; + + /** @internal */ + readonly [InternalDataSym]: InternalData; +} + +/** @internal */ +interface InternalData { + /** Digits as a big it */ + readonly n: bigint; + /** Exponent */ + readonly e: number; + /** Number of decimal digits */ + readonly d: number; + /** Sign */ + readonly s: 1 | -1; +} + +export class InvalidNumericError extends Error { + readonly code = "InvalidNumeric"; +} + +/** @internal */ +export const InternalDataSym = Symbol.for("NumericInternalData"); + +/** + * Represent any possible numeric value + */ +export function Numeric(stringValue: string): Numeric { + if (new.target) { + throw new Error("Numeric is not a constructor"); + } + const data = parse(stringValue); + + const isInteger = data.d === 0; + const obj = { + [InternalDataSym]: data, + isInteger, + }; + // We are explicitly not using a class here due to version mismatch between the compiler and the runtime that could happen and break instanceof checks. + const numeric = setTypedProptotype(obj, NumericPrototype); + return Object.freeze(numeric); +} + +function setTypedProptotype(obj: T, prototype: P): T & P { + Object.setPrototypeOf(obj, prototype); + return obj as any; +} + +function parse(original: string): InternalData { + let stringValue = original; + let start = 0; + let sign: 1 | -1 = 1; + let n: bigint; + let exp: number | undefined; + let decimal: number; + if (stringValue[0] === "-") { + start = 1; + sign = -1; + } + const second = stringValue[start + 1]?.toLowerCase(); + if (stringValue[start] === "0" && (second === "b" || second === "x" || second === "o")) { + try { + n = BigInt(stringValue.slice(start)); + exp = n.toString().length; + decimal = 0; + } catch { + throw new InvalidNumericError(`Invalid numeric value: ${original}`); + } + } else { + // Skip leading 0. + while (stringValue[start] === "0") { + start++; + } + const decimalPointIndex = stringValue.indexOf("."); + const adjustedPointIndex = decimalPointIndex - start; + // Decimal point? + if (decimalPointIndex !== -1) { + exp = adjustedPointIndex; + stringValue = stringValue.replace(".", ""); + } + + let i: number; + + if ((i = stringValue.search(/e/i)) > 0) { + // Determine exponent. + if (exp === undefined) { + exp = i - start; + } + exp += Number(stringValue.slice(i + 1)); + stringValue = stringValue.slice(start, i); + } else if (exp === undefined) { + // Integer. + exp = stringValue.length - start; + stringValue = stringValue.slice(start); + } else { + stringValue = stringValue.slice(start); + } + + let end = stringValue.length; + while (stringValue[end - 1] === "0") { + end--; + if (end < adjustedPointIndex) { + // if we are looking at a zero before the decimal point, we need to decrease the exponent + exp++; + } + } + try { + stringValue = stringValue.slice(0, end); + n = BigInt(stringValue); + decimal = n === 0n ? 0 : Math.max(stringValue.length - exp, 0); + } catch { + throw new InvalidNumericError(`Invalid numeric value: ${original}`); + } + } + + return { n, e: exp, s: sign, d: decimal }; +} + +function stringify(value: InternalData) { + const n = value.n.toString(); + const sign = value.s === -1 ? "-" : ""; + const extra = value.e > n.length ? "0".repeat(value.e - n.length) : ""; + const decimal = value.e < n.length ? "." + n.slice(value.e) : ""; + return sign + n.slice(0, value.e) + extra + decimal; +} +const equals = (a: InternalData, b: InternalData) => a.n === b.n && a.e === b.e; + +const compare = (a: InternalData, b: InternalData): 0 | 1 | -1 => { + if (a.s < b.s) { + return -1; + } else if (a.s > b.s) { + return 1; + } + if (a.e < b.e) { + return -1; + } else if (a.e > b.e) { + return 1; + } + + let aN = a.n; + let bN = b.n; + if (a.d < b.d) { + aN *= 10n ** BigInt(b.d - a.d); + } else { + bN *= 10n ** BigInt(a.d - b.d); + } + if (aN < bN) return -1; + if (aN > bN) return 1; + return 0; +}; + +const NumericPrototype = { + toString: function (this: Numeric) { + return stringify(this[InternalDataSym]); + }, + asNumber: function (this: Numeric) { + const num = Number(stringify(this[InternalDataSym])); + return equals(this[InternalDataSym], Numeric(num.toString())[InternalDataSym]) ? num : null; + }, + asBigInt: function (this: Numeric) { + return this.isInteger ? this[InternalDataSym].n : null; + }, + equals: function (this: Numeric, other: Numeric) { + return equals(this[InternalDataSym], other[InternalDataSym]); + }, + lt: function (this: Numeric, other: Numeric) { + return compare(this[InternalDataSym], other[InternalDataSym]) === -1; + }, + lte: function (this: Numeric, other: Numeric) { + return compare(this[InternalDataSym], other[InternalDataSym]) <= 0; + }, + gt: function (this: Numeric, other: Numeric) { + return compare(this[InternalDataSym], other[InternalDataSym]) === 1; + }, + gte: function (this: Numeric, other: Numeric) { + return compare(this[InternalDataSym], other[InternalDataSym]) >= 0; + }, +}; +NumericPrototype.toString = function (this: Numeric) { + return stringify(this[InternalDataSym]); +}; diff --git a/packages/compiler/test/core/numeric.test.ts b/packages/compiler/test/core/numeric.test.ts new file mode 100644 index 0000000000..6493fac690 --- /dev/null +++ b/packages/compiler/test/core/numeric.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; +import { InternalDataSym, Numeric } from "../../src/core/numeric.js"; + +describe("instantiate", () => { + it("with Numeric()", () => { + const numeric = Numeric("123"); + expect(numeric.toString()).toEqual("123"); + }); + it("prevent new Numeric()", () => { + // @ts-expect-error 'new' expression, whose target lacks a construct signature + expect(() => new Numeric("123")).toThrow(new TypeError("Numeric is not a constructor")); + }); +}); +describe("parsing", () => { + function expectNumericData(value: string, n: bigint, e: number, sign: 1 | -1 = 1) { + const numeric = Numeric(value); + expect(numeric[InternalDataSym].n).toEqual(n); + expect(numeric[InternalDataSym].s).toEqual(sign); + expect(numeric[InternalDataSym].e).toEqual(e); + expect(numeric[InternalDataSym].d).toEqual(Math.max(n.toString().length - e, 0)); + } + + describe("invalid number", () => { + // cspell: ignore babc + it.each(["0babc", "0xGHI", "0o999", "a123", "1d.3"])("%s", (a) => { + expect(() => Numeric(a)).toThrow(`Invalid numeric value: ${a}`); + }); + }); + + describe("decimal format", () => { + it("simple interger", () => { + expectNumericData("123", 123n, 3); + }); + + it("negative ingeger", () => { + expectNumericData("-123", 123n, 3, -1); + }); + + it("simple decimal", () => { + expectNumericData("123.456", 123456n, 3); + }); + it("negative decimal", () => { + expectNumericData("-123.456", 123456n, 3, -1); + }); + + it("decimal with leading 0", () => { + expectNumericData("123.00456", 12300456n, 3); + }); + + it("large integer (> Number.MAX_SAFE_INTEGER)", () => { + expectNumericData("123456789123456789", 123456789123456789n, 18); + }); + + it("large decimal (> Number.MAX_VALUE)", () => { + expectNumericData( + "123456789123456789.112233445566778899", + 123456789123456789112233445566778899n, + 18 + ); + }); + }); + + describe("binary format", () => { + it("lower case b", () => { + expectNumericData("0b10000000000000000000000000000000", 2147483648n, 10); + }); + + it("upper case B", () => { + expectNumericData("0B10000000000000000000000000000000", 2147483648n, 10); + }); + + it("large (> Number.MAX_SAFE_INTEGER)", () => { + expectNumericData( + "0b1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + 9903520314283042199192993792n, + 28 + ); + }); + }); + describe("octal format", () => { + it("lower case o", () => { + expectNumericData("0o755", 493n, 3); + }); + it("upper case O", () => { + expectNumericData("0O755", 493n, 3); + }); + it("large (> Number.MAX_SAFE_INTEGER)", () => { + expectNumericData("0O755755755755755755755755", 4556020892475019746285n, 22); + }); + }); + + describe("hexadecimal format", () => { + it("lower case x", () => { + expectNumericData("0xA", 10n, 2); + }); + it("upper case X", () => { + expectNumericData("0XA", 10n, 2); + }); + it("large (> Number.MAX_SAFE_INTEGER)", () => { + expectNumericData("0xFFFFFFFFFFFFFFFFF", 295147905179352825855n, 21); + }); + }); + describe("exponent format", () => { + it("5e1", () => { + expectNumericData("5e1", 5n, 2); + }); + it("5e2", () => { + expectNumericData("5e2", 5n, 3); + }); + + it("1.5e2", () => { + expectNumericData("1.5e2", 15n, 3); + }); + + it("5e-1", () => { + expectNumericData("5e-1", 5n, 0); + }); + it("5e-2", () => { + expectNumericData("5e-2", 5n, -1); + }); + it("15e-4", () => { + expectNumericData("15e-4", 15n, -2); + }); + it("00015e-4", () => { + expectNumericData("00015e-4", 15n, -2); + }); + it("large (> Number.MAX_SAFE_INTEGER)", () => { + expectNumericData("5e64", 5n, 65); + }); + }); +}); + +describe("asString", () => { + it("doesn't include decimal if is an integer", () => { + expect(Numeric("123").toString()).toEqual("123"); + }); + + it("include sign", () => { + expect(Numeric("-123").toString()).toEqual("-123"); + }); + + it("render large integer", () => { + expect(Numeric("123456789123456789").toString()).toEqual("123456789123456789"); + }); + it("decimals", () => { + expect(Numeric("-123.456").toString()).toEqual("-123.456"); + }); + it("data with exponent", () => { + expect(Numeric("5e6").toString()).toEqual("5000000"); + }); + it("data with decimal", () => { + expect(Numeric("0xFFFFFFFFFFFFFFFFF").toString()).toEqual("295147905179352825855"); + }); + it("keeps decimal zeros", () => { + expect(Numeric("123.0000456").toString()).toEqual("123.0000456"); + }); + + it("simplify leading zeros", () => { + expect(Numeric("000123").toString()).toEqual("123"); + }); +}); + +describe("asNumber", () => { + it.each([ + ["0", 0], + ["0.0", 0], + ["123", 123], + ["123.456", 123.456], + ["123.00", 123], + ["123456789123456789123456789123456789", null], + ["123456789123456789.123456789123456789", null], + ])("%s => %d", (a, b) => { + const numeric = Numeric(a); + expect(numeric.asNumber()).toEqual(b); + }); +}); +describe("asBigInt", () => { + it.each([ + ["0", 0n], + ["0.0", 0n], + ["123", 123n], + ["123.456", null], + ["123.00", 123n], + ["123456789123456789123456789123456789", 123456789123456789123456789123456789n], + ["123456789123456789.123456789123456789", null], + ])("%s => %d", (a, b) => { + const numeric = Numeric(a); + expect(numeric.asBigInt()).toEqual(b); + }); +}); + +describe("equals", () => { + describe("equals", () => { + it.each([ + ["0", "0"], + ["0", "0.0"], + ["123", "123"], + ["123", "123.0"], + ["123", "123.000"], + ["123.00", "123.000"], + ["123.4500", "123.45000"], + ["123456789123456789", "123456789123456789"], + ["123456789123456789.123456789123456789", "123456789123456789.123456789123456789"], + ["123.456", "123.456"], + ["123.006", "123.006"], + ["00123", "123"], + ["1.2e1", "12"], + ["1.2e2", "120"], + ["12e2", "1200"], + ["1200.45006e2", "120045.006"], + ])("%s === %s", (a, b) => expect(Numeric(a).equals(Numeric(b))).toBe(true)); + }); + + describe("not equals", () => { + it.each([ + ["2", "1"], + ["123.6", "123.006"], + ["123", "1230"], + ])("%s === %s", (a, b) => expect(Numeric(a).equals(Numeric(b))).toBe(false)); + }); +}); + +describe("compare", () => { + describe("lt", () => { + it.each([ + ["0", "1"], + ["0", "0.1"], + ["123", "123.00001"], + ["123.001", "123.01"], + ["123456789123456789.123456789123456789", "123456789123456789.1234567891234567891"], + ])("%s < %s", (a, b) => { + expect(Numeric(a).lt(Numeric(b))).toBe(true); + expect(Numeric(b).lt(Numeric(a))).toBe(false); + }); + }); + describe("lte", () => { + it.each([ + ["0", "0"], + ["0", "0.0"], + ["0", "1"], + ["0", "0.1"], + ["123", "123.00001"], + ["123.001", "123.01"], + ["123456789123456789.123456789123456789", "123456789123456789.1234567891234567891"], + ])("%s < %s", (a, b) => { + expect(Numeric(a).lte(Numeric(b))).toBe(true); + }); + }); + + describe("gt", () => { + it.each([ + ["1", "0"], + ["0.1", "0"], + ["123.00001", "123"], + ["123.01", "123.001"], + ["123456789123456789.1234567891234567891", "123456789123456789.123456789123456789"], + ])("%s < %s", (a, b) => { + expect(Numeric(a).gt(Numeric(b))).toBe(true); + expect(Numeric(b).gt(Numeric(a))).toBe(false); + }); + }); + describe("gte", () => { + it.each([ + ["0", "0"], + ["0", "0.0"], + ["1", "0"], + ["0.1", "0"], + ["123.00001", "123"], + ["123.01", "123.001"], + ["123456789123456789.1234567891234567891", "123456789123456789.123456789123456789"], + ])("%s < %s", (a, b) => { + expect(Numeric(a).gte(Numeric(b))).toBe(true); + }); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index 3a792ed1ba..e509b3486c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -15,9 +15,9 @@ "declaration": true, "stripInternal": true, "noEmitHelpers": false, - "target": "es2019", + "target": "ES2022", "types": ["node"], - "lib": ["es2019", "DOM"], + "lib": ["es2022", "DOM"], "experimentalDecorators": true, "newLine": "LF" }