diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11c47c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v1.x + + - name: Format + run: deno fmt --check + + - name: Lint + run: deno lint + + - name: Check + run: deno task check + + - name: Test + run: deno test + + - name: Publish to JSR + run: deno publish \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fff3a34 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +deno.lock diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cbac569 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "deno.enable": true +} diff --git a/Decimal.test.ts b/Decimal.test.ts new file mode 100644 index 0000000..2eddc88 --- /dev/null +++ b/Decimal.test.ts @@ -0,0 +1,500 @@ +import assert from '@quentinadam/assert'; +import Decimal from './Decimal.ts'; + +function wrap(fn: () => void) { + try { + fn(); + return undefined; + } catch (e) { + return e; + } +} + +Deno.test('fromBigInt', () => { + const vectors = [ + { input: 123n, mantissa: 123n, exponent: 0 }, + { input: 120n, mantissa: 12n, exponent: 1 }, + { input: -123n, mantissa: -123n, exponent: 0 }, + { input: -120n, mantissa: -12n, exponent: 1 }, + { input: 0n, mantissa: 0n, exponent: 0 }, + ]; + for (const { input, mantissa, exponent } of vectors) { + const value = Decimal.fromBigInt(input); + assert(value.mantissa === mantissa && value.exponent === exponent); + } +}); + +Deno.test('fromString', () => { + const vectors = [ + { input: '123', mantissa: 123n, exponent: 0 }, + { input: '-123', mantissa: -123n, exponent: 0 }, + { input: '120', mantissa: 12n, exponent: 1 }, + { input: '-120', mantissa: -12n, exponent: 1 }, + { input: '0', mantissa: 0n, exponent: 0 }, + { input: '123.0', mantissa: 123n, exponent: 0 }, + { input: '-123.0', mantissa: -123n, exponent: 0 }, + { input: '120.0', mantissa: 12n, exponent: 1 }, + { input: '-120.0', mantissa: -12n, exponent: 1 }, + { input: '0.0', mantissa: 0n, exponent: 0 }, + { input: '123.45', mantissa: 12345n, exponent: -2 }, + { input: '-123.45', mantissa: -12345n, exponent: -2 }, + { input: '123e2', mantissa: 123n, exponent: 2 }, + { input: '-123e2', mantissa: -123n, exponent: 2 }, + { input: '1.23e1', mantissa: 123n, exponent: -1 }, + { input: '-1.23e1', mantissa: -123n, exponent: -1 }, + { input: '123e-2', mantissa: 123n, exponent: -2 }, + { input: '-123e-2', mantissa: -123n, exponent: -2 }, + { input: '1230e-2', mantissa: 123n, exponent: -1 }, + { input: '-1230e-2', mantissa: -123n, exponent: -1 }, + { input: '1.23e-2', mantissa: 123n, exponent: -4 }, + { input: '-1.23e-2', mantissa: -123n, exponent: -4 }, + { input: '123456789.123456789123456789', mantissa: 123456789123456789123456789n, exponent: -18 }, + { input: '-123456789.123456789123456789', mantissa: -123456789123456789123456789n, exponent: -18 }, + ]; + for (const { input, mantissa, exponent } of vectors) { + const value = Decimal.fromString(input); + assert(value.mantissa === mantissa && value.exponent === exponent); + } +}); + +Deno.test('toString', () => { + const vectors = [ + { input: Decimal.from(123), output: '123' }, + { input: Decimal.from(12.30), output: '12.3' }, + { input: Decimal.from(0.123), output: '0.123' }, + { input: Decimal.from(0.0123), output: '0.0123' }, + { input: Decimal.from(-123), output: '-123' }, + { input: Decimal.from(-12.30), output: '-12.3' }, + { input: Decimal.from(-0.123), output: '-0.123' }, + { input: Decimal.from(-0.0123), output: '-0.0123' }, + { input: Decimal.from('123456789.123456789123456789'), output: '123456789.123456789123456789' }, + { input: Decimal.from('-123456789.123456789123456789'), output: '-123456789.123456789123456789' }, + ]; + for (const { input, output } of vectors) { + assert(input.toString() === output); + } +}); + +Deno.test('toFixed', () => { + const vectors = [ + { input: Decimal.from(123), precision: 0, output: '123' }, + { input: Decimal.from(-123), precision: 0, output: '-123' }, + { input: Decimal.from(123), precision: 1, output: '123.0' }, + { input: Decimal.from(-123), precision: 1, output: '-123.0' }, + { input: Decimal.from(123.8), precision: 0, output: '124' }, + { input: Decimal.from(-123.8), precision: 0, output: '-124' }, + { input: Decimal.from(123.45), precision: 1, output: '123.5' }, + { input: Decimal.from(-123.45), precision: 1, output: '-123.5' }, + ]; + for (const { input, precision, output } of vectors) { + assert(input.toFixed(precision) === output); + } +}); + +Deno.test('eq', () => { + const vectors = [ + { a: Decimal.from(123), b: Decimal.from(123), output: true }, + { a: Decimal.from(123), b: Decimal.from(456), output: false }, + { a: Decimal.from(-123), b: Decimal.from(-123), output: true }, + { a: Decimal.from(-123), b: Decimal.from(-456), output: false }, + ]; + for (const { a, b, output } of vectors) { + assert(a.eq(b) === output); + } +}); + +Deno.test('add', () => { + const vectors = [ + { a: Decimal.from(123), b: Decimal.from(456), output: Decimal.from(579) }, + { a: Decimal.from(1000), b: Decimal.from(456), output: Decimal.from(1456) }, + { a: Decimal.from(1000), b: Decimal.from(45.6), output: Decimal.from(1045.6) }, + { a: Decimal.from(1000), b: Decimal.from(-45.6), output: Decimal.from(954.4) }, + { a: Decimal.from(-1000), b: Decimal.from(-45.6), output: Decimal.from(-1045.6) }, + ]; + for (const { a, b, output } of vectors) { + assert(a.add(b).eq(output)); + } +}); + +Deno.test('sub', () => { + const vectors = [ + { a: Decimal.from(123), b: Decimal.from(456), output: Decimal.from(-333) }, + { a: Decimal.from(1000), b: Decimal.from(456), output: Decimal.from(544) }, + { a: Decimal.from(1000), b: Decimal.from(45.6), output: Decimal.from(954.4) }, + { a: Decimal.from(1000), b: Decimal.from(-45.6), output: Decimal.from(1045.6) }, + { a: Decimal.from(-1000), b: Decimal.from(-45.6), output: Decimal.from(-954.4) }, + ]; + for (const { a, b, output } of vectors) { + assert(a.sub(b).eq(output)); + } +}); + +Deno.test('mul', () => { + const vectors = [ + { a: Decimal.from(123), b: Decimal.from(456), output: Decimal.from(56088) }, + { a: Decimal.from(1000), b: Decimal.from(456), output: Decimal.from(456000) }, + { a: Decimal.from(1000), b: Decimal.from(45.6), output: Decimal.from(45600) }, + { a: Decimal.from(1000), b: Decimal.from(-45.6), output: Decimal.from(-45600) }, + { a: Decimal.from(-1000), b: Decimal.from(-45.6), output: Decimal.from(45600) }, + ]; + for (const { a, b, output } of vectors) { + assert(a.mul(b).eq(output)); + } +}); + +Deno.test('fromFraction', () => { + const vectors: + ({ numerator: bigint; denominator: bigint } & ({ success: true; output: Decimal } | { success: false }))[] = [ + { numerator: 1n, denominator: 2n, success: true, output: Decimal.from(0.5) }, + { numerator: 3n, denominator: 12n, success: true, output: Decimal.from(0.25) }, + { numerator: 1n, denominator: 3n, success: false }, + ]; + for (const vector of vectors) { + const { numerator, denominator } = vector; + if (vector.success) { + assert(Decimal.fromFraction(numerator, denominator).eq(vector.output)); + } else { + assert(wrap(() => Decimal.fromFraction(numerator, denominator)) instanceof Error); + } + } +}); + +Deno.test('toFraction', () => { + const vectors: { input: Decimal; numerator: bigint; denominator: bigint }[] = [ + { input: Decimal.from(123), numerator: 123n, denominator: 1n }, + { input: Decimal.from(-123), numerator: -123n, denominator: 1n }, + { input: Decimal.from(1000), numerator: 1000n, denominator: 1n }, + { input: Decimal.from(0), numerator: 0n, denominator: 1n }, + { input: Decimal.from('123456789123456789'), numerator: 123456789123456789n, denominator: 1n }, + { input: Decimal.from('-123456789123456789'), numerator: -123456789123456789n, denominator: 1n }, + { input: Decimal.from(1.23), numerator: 123n, denominator: 100n }, + { input: Decimal.from(-1.23), numerator: -123n, denominator: 100n }, + { input: Decimal.from(0.5), numerator: 1n, denominator: 2n }, + { input: Decimal.from(0.25), numerator: 1n, denominator: 4n }, + { input: Decimal.from(0.125), numerator: 1n, denominator: 8n }, + { input: Decimal.from(123.456), numerator: 15432n, denominator: 125n }, + { input: Decimal.from(-123.456), numerator: -15432n, denominator: 125n }, + { input: Decimal.from('1.23456789123456789'), numerator: 123456789123456789n, denominator: 100000000000000000n }, + ]; + for (const { input, numerator, denominator } of vectors) { + const fraction = input.toFraction(); + assert( + fraction.numerator === numerator && fraction.denominator === denominator, + `Expected: ${input}, (${numerator}, ${denominator}), Got: (${fraction.numerator}, ${fraction.denominator})`, + ); + } +}); + +Deno.test('div', () => { + const vectors: ({ a: Decimal; b: Decimal } & ({ success: true; output: Decimal } | { success: false }))[] = [ + { a: Decimal.from(1000), b: Decimal.from(2), success: true, output: Decimal.from(500) }, + { a: Decimal.from(2), b: Decimal.from(1000), success: true, output: Decimal.from(0.002) }, + { a: Decimal.from(1000), b: Decimal.from(0.2), success: true, output: Decimal.from(5000) }, + { a: Decimal.from(1), b: Decimal.from(8), success: true, output: Decimal.from(0.125) }, + { a: Decimal.from(141), b: Decimal.from(376), success: true, output: Decimal.from(0.375) }, + { a: Decimal.from(1), b: Decimal.from(3), success: false }, + { a: Decimal.from(1000), b: Decimal.from(30), success: false }, + ]; + for (const vector of vectors) { + const { a, b } = vector; + if (vector.success) { + assert(a.div(b).eq(vector.output)); + } else { + assert(wrap(() => a.div(b)) instanceof Error); + } + } +}); + +Deno.test('inv', () => { + const vectors: ({ input: Decimal } & ({ success: true; output: Decimal } | { success: false }))[] = [ + { input: Decimal.from(1000), success: true, output: Decimal.from(0.001) }, + { input: Decimal.from(0.2), success: true, output: Decimal.from(5) }, + { input: Decimal.from(1), success: true, output: Decimal.from(1) }, + { input: Decimal.from(0.5), success: true, output: Decimal.from(2) }, + { input: Decimal.from(0.25), success: true, output: Decimal.from(4) }, + { input: Decimal.from(3), success: false }, + ]; + for (const vector of vectors) { + const { input } = vector; + if (vector.success) { + assert(input.inv().eq(vector.output), input.toString()); + } else { + assert(wrap(() => input.inv()) instanceof Error); + } + } +}); + +Deno.test('mod', () => { + const vectors = [ + { a: Decimal.from(1000), b: Decimal.from(2), output: Decimal.from(0) }, + { a: Decimal.from(1000), b: Decimal.from(3), output: Decimal.from(1) }, + { a: Decimal.from(1000), b: Decimal.from(0.2), output: Decimal.from(0) }, + { a: Decimal.from(1000), b: Decimal.from(0.3), output: Decimal.from(0.1) }, + { a: Decimal.from(1), b: Decimal.from(8), output: Decimal.from(1) }, + { a: Decimal.from(17.5), b: Decimal.from(3), output: Decimal.from(2.5) }, + { a: Decimal.from(1), b: Decimal.from(3), output: Decimal.from(1) }, + { a: Decimal.from(1000), b: Decimal.from(30), output: Decimal.from(10) }, + ]; + for (const { a, b, output } of vectors) { + assert(a.mod(b).eq(output)); + } +}); + +Deno.test('pow', () => { + const vectors: ({ a: Decimal; b: number } & ({ success: true; output: Decimal } | { success: false }))[] = [ + { a: Decimal.from(2), b: 1, success: true, output: Decimal.from(2) }, + { a: Decimal.from(2), b: 0, success: true, output: Decimal.from(1) }, + { a: Decimal.from(2), b: 2, success: true, output: Decimal.from(4) }, + { a: Decimal.from(2), b: -1, success: true, output: Decimal.from(0.5) }, + { a: Decimal.from(2), b: -2, success: true, output: Decimal.from(0.25) }, + { a: Decimal.from(0.2), b: 1, success: true, output: Decimal.from(0.2) }, + { a: Decimal.from(0.2), b: 0, success: true, output: Decimal.from(1) }, + { a: Decimal.from(0.2), b: 2, success: true, output: Decimal.from(0.04) }, + { a: Decimal.from(0.2), b: -1, success: true, output: Decimal.from(5) }, + { a: Decimal.from(0.2), b: -2, success: true, output: Decimal.from(25) }, + { a: Decimal.from(3), b: 1, success: true, output: Decimal.from(3) }, + { a: Decimal.from(3), b: 0, success: true, output: Decimal.from(1) }, + { a: Decimal.from(3), b: 2, success: true, output: Decimal.from(9) }, + { a: Decimal.from(3), b: -1, success: false }, + { a: Decimal.from(-2), b: 1, success: true, output: Decimal.from(-2) }, + { a: Decimal.from(-2), b: 0, success: true, output: Decimal.from(1) }, + { a: Decimal.from(-2), b: 2, success: true, output: Decimal.from(4) }, + { a: Decimal.from(-2), b: -1, success: true, output: Decimal.from(-0.5) }, + { a: Decimal.from(-2), b: -2, success: true, output: Decimal.from(0.25) }, + ]; + for (const vector of vectors) { + const { a, b } = vector; + if (vector.success) { + assert(a.pow(b).eq(vector.output)); + } else { + assert(wrap(() => a.pow(b)) instanceof Error); + } + } +}); + +Deno.test('abs', () => { + const vectors = [ + { input: Decimal.from(123), output: Decimal.from(123) }, + { input: Decimal.from(-123), output: Decimal.from(123) }, + { input: Decimal.from(0), output: Decimal.from(0) }, + { input: Decimal.from(12.3), output: Decimal.from(12.3) }, + { input: Decimal.from(-12.3), output: Decimal.from(12.3) }, + ]; + for (const { input, output } of vectors) { + assert(input.abs().eq(output)); + } +}); + +Deno.test('neg', () => { + const vectors = [ + { input: Decimal.from(123), output: Decimal.from(-123) }, + { input: Decimal.from(-123), output: Decimal.from(123) }, + { input: Decimal.from(0), output: Decimal.from(0) }, + { input: Decimal.from(12.3), output: Decimal.from(-12.3) }, + { input: Decimal.from(-12.3), output: Decimal.from(12.3) }, + ]; + for (const { input, output } of vectors) { + assert(input.neg().eq(output)); + } +}); + +Deno.test('floor', () => { + const vectors = [ + { input: Decimal.from(123), output: Decimal.from(123) }, + { input: Decimal.from(12.3), output: Decimal.from(12) }, + { input: Decimal.from(12.5), output: Decimal.from(12) }, + { input: Decimal.from(12.7), output: Decimal.from(12) }, + { input: Decimal.from(0), output: Decimal.from(0) }, + { input: Decimal.from(-12.3), output: Decimal.from(-13) }, + { input: Decimal.from(-12.5), output: Decimal.from(-13) }, + { input: Decimal.from(-12.7), output: Decimal.from(-13) }, + { input: Decimal.from(-123), output: Decimal.from(-123) }, + ]; + for (const { input, output } of vectors) { + assert(input.floor().eq(output)); + } +}); + +Deno.test('ceil', () => { + const vectors = [ + { input: Decimal.from(123), output: Decimal.from(123) }, + { input: Decimal.from(12.3), output: Decimal.from(13) }, + { input: Decimal.from(12.5), output: Decimal.from(13) }, + { input: Decimal.from(12.7), output: Decimal.from(13) }, + { input: Decimal.from(0), output: Decimal.from(0) }, + { input: Decimal.from(-12.3), output: Decimal.from(-12) }, + { input: Decimal.from(-12.5), output: Decimal.from(-12) }, + { input: Decimal.from(-12.7), output: Decimal.from(-12) }, + { input: Decimal.from(-123), output: Decimal.from(-123) }, + ]; + for (const { input, output } of vectors) { + assert(input.ceil().eq(output)); + } +}); + +Deno.test('round', () => { + const vectors = [ + { input: Decimal.from(123), output: Decimal.from(123) }, + { input: Decimal.from(12.3), output: Decimal.from(12) }, + { input: Decimal.from(12.5), output: Decimal.from(13) }, + { input: Decimal.from(12.7), output: Decimal.from(13) }, + { input: Decimal.from(0), output: Decimal.from(0) }, + { input: Decimal.from(-12.3), output: Decimal.from(-12) }, + { input: Decimal.from(-12.5), output: Decimal.from(-12) }, + { input: Decimal.from(-12.7), output: Decimal.from(-13) }, + { input: Decimal.from(-123), output: Decimal.from(-123) }, + ]; + for (const { input, output } of vectors) { + assert(input.round().eq(output)); + } +}); + +Deno.test('magnitude', () => { + const vectors = [ + { input: Decimal.from(999), output: 2 }, + { input: Decimal.from(123), output: 2 }, + { input: Decimal.from(100), output: 2 }, + { input: Decimal.from(99.9), output: 1 }, + { input: Decimal.from(12.3), output: 1 }, + { input: Decimal.from(10), output: 1 }, + { input: Decimal.from(9.99), output: 0 }, + { input: Decimal.from(1.23), output: 0 }, + { input: Decimal.from(1), output: 0 }, + { input: Decimal.from(0.999), output: -1 }, + { input: Decimal.from(0.123), output: -1 }, + { input: Decimal.from(0.1), output: -1 }, + { input: Decimal.from(0.0999), output: -2 }, + { input: Decimal.from(0.0123), output: -2 }, + { input: Decimal.from(0.01), output: -2 }, + ]; + for (const { input, output } of vectors) { + assert(input.magnitude() === output, input.toString()); + } +}); + +Deno.test('toBigInt', () => { + const vectors: ({ input: Decimal } & ({ success: true; output: bigint } | { success: false }))[] = [ + { input: Decimal.from(123), success: true, output: 123n }, + { input: Decimal.from(-123), success: true, output: -123n }, + { input: Decimal.from(1000), success: true, output: 1000n }, + { input: Decimal.from(0), success: true, output: 0n }, + { input: Decimal.from('123456789123456789'), success: true, output: 123456789123456789n }, + { input: Decimal.from('-123456789123456789'), success: true, output: -123456789123456789n }, + { input: Decimal.from('123e3'), success: true, output: 123000n }, + { input: Decimal.from('-123e3'), success: true, output: -123000n }, + { input: Decimal.from(1.23), success: false }, + { input: Decimal.from(-1.23), success: false }, + ]; + for (const vector of vectors) { + const { input } = vector; + if (vector.success) { + assert(input.toBigInt() === vector.output); + } else { + assert(wrap(() => input.toBigInt()) instanceof Error); + } + } +}); + +Deno.test('gt', () => { + const vectors = [ + { a: Decimal.from(123), b: Decimal.from(100), output: true }, + { a: Decimal.from(123), b: Decimal.from(123), output: false }, + { a: Decimal.from(100), b: Decimal.from(123), output: false }, + { a: Decimal.from(-100), b: Decimal.from(-123), output: true }, + { a: Decimal.from(-123), b: Decimal.from(-123), output: false }, + { a: Decimal.from(-123), b: Decimal.from(-100), output: false }, + { a: Decimal.from(0), b: Decimal.from(0), output: false }, + ]; + for (const { a, b, output } of vectors) { + assert(a.gt(b) === output); + } +}); + +Deno.test('gte', () => { + const vectors = [ + { a: Decimal.from(123), b: Decimal.from(100), output: true }, + { a: Decimal.from(123), b: Decimal.from(123), output: true }, + { a: Decimal.from(100), b: Decimal.from(123), output: false }, + { a: Decimal.from(-100), b: Decimal.from(-123), output: true }, + { a: Decimal.from(-123), b: Decimal.from(-123), output: true }, + { a: Decimal.from(-123), b: Decimal.from(-100), output: false }, + { a: Decimal.from(0), b: Decimal.from(0), output: true }, + ]; + for (const { a, b, output } of vectors) { + assert(a.gte(b) === output); + } +}); + +Deno.test('lt', () => { + const vectors = [ + { a: Decimal.from(123), b: Decimal.from(100), output: false }, + { a: Decimal.from(123), b: Decimal.from(123), output: false }, + { a: Decimal.from(100), b: Decimal.from(123), output: true }, + { a: Decimal.from(-100), b: Decimal.from(-123), output: false }, + { a: Decimal.from(-123), b: Decimal.from(-123), output: false }, + { a: Decimal.from(-123), b: Decimal.from(-100), output: true }, + { a: Decimal.from(0), b: Decimal.from(0), output: false }, + ]; + for (const { a, b, output } of vectors) { + assert(a.lt(b) === output); + } +}); + +Deno.test('lte', () => { + const vectors = [ + { a: Decimal.from(123), b: Decimal.from(100), output: false }, + { a: Decimal.from(123), b: Decimal.from(123), output: true }, + { a: Decimal.from(100), b: Decimal.from(123), output: true }, + { a: Decimal.from(-100), b: Decimal.from(-123), output: false }, + { a: Decimal.from(-123), b: Decimal.from(-123), output: true }, + { a: Decimal.from(-123), b: Decimal.from(-100), output: true }, + { a: Decimal.from(0), b: Decimal.from(0), output: true }, + ]; + for (const { a, b, output } of vectors) { + assert(a.lte(b) === output); + } +}); + +Deno.test('gt0', () => { + const vectors = [ + { input: Decimal.from(123), output: true }, + { input: Decimal.from(0), output: false }, + { input: Decimal.from(-123), output: false }, + ]; + for (const { input, output } of vectors) { + assert(input.gt0() === output); + } +}); + +Deno.test('gte0', () => { + const vectors = [ + { input: Decimal.from(123), output: true }, + { input: Decimal.from(0), output: true }, + { input: Decimal.from(-123), output: false }, + ]; + for (const { input, output } of vectors) { + assert(input.gte0() === output); + } +}); + +Deno.test('lt0', () => { + const vectors = [ + { input: Decimal.from(123), output: false }, + { input: Decimal.from(0), output: false }, + { input: Decimal.from(-123), output: true }, + ]; + for (const { input, output } of vectors) { + assert(input.lt0() === output); + } +}); + +Deno.test('lte0', () => { + const vectors = [ + { input: Decimal.from(123), output: false }, + { input: Decimal.from(0), output: true }, + { input: Decimal.from(-123), output: true }, + ]; + for (const { input, output } of vectors) { + assert(input.lte0() === output); + } +}); \ No newline at end of file diff --git a/Decimal.ts b/Decimal.ts new file mode 100644 index 0000000..983b311 --- /dev/null +++ b/Decimal.ts @@ -0,0 +1,388 @@ +import assert from '@quentinadam/assert'; + +function gcd(a: bigint, b: bigint) { + while (b !== 0n) { + const t = b; + b = a % b; + a = t; + } + return a; +} + +export default class Decimal { + readonly mantissa: bigint; + readonly exponent: number; + + constructor(mantissa: bigint, exponent = 0) { + assert(Number.isInteger(exponent), `Exponent must be an integer, got ${exponent}`); + if (mantissa === 0n) { + this.mantissa = 0n; + this.exponent = 0; + } else { + while (mantissa % 10n === 0n) { + mantissa /= 10n; + exponent += 1; + } + this.mantissa = mantissa; + this.exponent = exponent; + } + } + + abs(): Decimal { + return this.gt0() ? this : this.neg(); + } + + #normalize(value1: Decimal, value2: Decimal) { + const exponent = Math.min(value1.exponent, value2.exponent); + return { + mantissa1: value1.mantissa * 10n ** BigInt(value1.exponent - exponent), + mantissa2: value2.mantissa * 10n ** BigInt(value2.exponent - exponent), + exponent, + }; + } + + add(value: Decimal | number | bigint | string): Decimal { + value = Decimal.from(value); + const { mantissa1, mantissa2, exponent } = this.#normalize(this, value); + return new Decimal(mantissa1 + mantissa2, exponent); + } + + ceil(): Decimal { + if (this.exponent >= 0) { + return this; + } + const result = this.mantissa / 10n ** BigInt(-this.exponent); + return this.gte0() ? new Decimal(result + 1n) : new Decimal(result); + } + + div(value: Decimal | number | bigint | string): Decimal { + value = Decimal.from(value); + const { numerator: numerator1, denominator: denominator1 } = this.toFraction(); + const { numerator: numerator2, denominator: denominator2 } = value.toFraction(); + return Decimal.fromFraction(numerator1 * denominator2, numerator2 * denominator1); + } + + e(exponent: bigint | number): Decimal { + return this.mul(new Decimal(10n).pow(exponent)); + } + + eq(value: Decimal | number | bigint | string): boolean { + value = Decimal.from(value); + const { mantissa1, mantissa2 } = this.#normalize(this, value); + return mantissa1 === mantissa2; + } + + eq0(): boolean { + return this.mantissa === 0n; + } + + floor(): Decimal { + if (this.exponent >= 0) { + return this; + } + const result = this.mantissa / 10n ** BigInt(-this.exponent); + return this.gte0() ? new Decimal(result) : new Decimal(result - 1n); + } + + gt(value: Decimal | number | bigint | string): boolean { + value = Decimal.from(value); + const { mantissa1, mantissa2 } = this.#normalize(this, value); + return mantissa1 > mantissa2; + } + + gt0(): boolean { + return this.mantissa > 0n; + } + + gte(value: Decimal | number | bigint | string): boolean { + value = Decimal.from(value); + const { mantissa1, mantissa2 } = this.#normalize(this, value); + return mantissa1 >= mantissa2; + } + + gte0(): boolean { + return this.mantissa >= 0n; + } + + inv(): Decimal { + return Decimal.one.div(this); + } + + isInteger(): boolean { + return this.exponent >= 0; + } + + lt(value: Decimal | number | bigint | string): boolean { + value = Decimal.from(value); + const { mantissa1, mantissa2 } = this.#normalize(this, value); + return mantissa1 < mantissa2; + } + + lt0(): boolean { + return this.mantissa < 0n; + } + + lte(value: Decimal | number | bigint | string): boolean { + value = Decimal.from(value); + const { mantissa1, mantissa2 } = this.#normalize(this, value); + return mantissa1 <= mantissa2; + } + + lte0(): boolean { + return this.mantissa <= 0n; + } + + magnitude(): number { + if (this.lt0()) { + return this.neg().magnitude(); + } + let mantissa = 1n; + let exponent = -1; + while (this.mantissa >= mantissa) { + mantissa *= 10n; + exponent += 1; + } + return exponent + this.exponent; + } + + mod(value: Decimal): Decimal { + const { mantissa1, mantissa2, exponent } = this.#normalize(this, value); + return new Decimal(mantissa1 % mantissa2, exponent); + } + + mul(value: Decimal | number | bigint | string): Decimal { + value = Decimal.from(value); + return new Decimal(this.mantissa * value.mantissa, this.exponent + value.exponent); + } + + neg(): Decimal { + return new Decimal(-this.mantissa, this.exponent); + } + + neq(value: Decimal | number | bigint | string): boolean { + return !this.eq(value); + } + + neq0(): boolean { + return !this.eq0(); + } + + pow(value: bigint | number): Decimal { + if (typeof value === 'number') { + assert(Number.isInteger(value), `Exponent must be an integer, got ${value}`); + value = BigInt(value); + } + if (value === 0n) { + return Decimal.one; + } else if (value === 1n) { + return this; + } else if (value < 0n) { + return this.inv().pow(-value); + } else { + return new Decimal(this.mantissa ** value, this.exponent * 2); + } + } + + round(): Decimal { + return this.add(new Decimal(5n, -1)).floor(); + } + + sign(): Decimal { + if (this.gt0()) { + return Decimal.one; + } + if (this.lt0()) { + return Decimal.minusOne; + } + return Decimal.zero; + } + + compare(other: Decimal | number | bigint | string): number { + return this.sub(other).sign().toNumber(); + } + + sub(value: Decimal | number | bigint | string): Decimal { + value = Decimal.from(value); + const { mantissa1, mantissa2, exponent } = this.#normalize(this, value); + return new Decimal(mantissa1 - mantissa2, exponent); + } + + toBigInt(): bigint { + assert(this.isInteger(), `Decimal ${this} is not an integer`); + return this.mantissa * 10n ** BigInt(this.exponent); + } + + toFixed(precision: number): string { + assert(Number.isInteger(precision) && precision >= 0, `Precision must be a non-negative integer, got ${precision}`); + if (this.lt0()) { + return '-' + this.neg().toFixed(precision); + } + const value = this.e(precision).round().e(-precision); + if (value.exponent >= 0) { + const result = value.mantissa.toString() + '0'.repeat(value.exponent); + return precision > 0 ? result + '.' + '0'.repeat(precision) : result; + } else { + const string = value.mantissa.toString().padStart(-value.exponent + 1, '0'); + return string.slice(0, value.exponent) + '.' + string.slice(value.exponent).padEnd(precision, '0'); + } + } + + toFraction(): { numerator: bigint; denominator: bigint } { + if (this.exponent >= 0) { + return { numerator: this.mantissa * 10n ** BigInt(this.exponent), denominator: 1n }; + } else { + const numerator = this.mantissa; + const denominator = 10n ** BigInt(-this.exponent); + const divisor = gcd(numerator, denominator); + return { numerator: numerator / divisor, denominator: denominator / divisor }; + } + } + + toJSON(): string { + return this.toString(); + } + + toNumber(): number { + return Number(this.toString()); + } + + toString(): string { + if (this.lt0()) { + return '-' + this.neg().toString(); + } + if (this.exponent >= 0) { + return this.mantissa.toString() + '0'.repeat(this.exponent); + } else { + const string = this.mantissa.toString().padStart(-this.exponent + 1, '0'); + return string.slice(0, this.exponent) + '.' + string.slice(this.exponent); + } + } + + [Symbol.for('Deno.customInspect')](): string { + return this.toString(); + } + + static minusOne: Decimal = new Decimal(-1n); + static one: Decimal = new Decimal(1n); + static zero: Decimal = new Decimal(0n); + + static add(...values: (Decimal | number | bigint | string)[]): Decimal { + return values.reduce((previous: Decimal, current) => { + return previous.add(current); + }, Decimal.zero); + } + + static div(base: Decimal | number | bigint | string, ...values: (Decimal | number | bigint | string)[]): Decimal { + return values.reduce((previous: Decimal, current) => { + return previous.div(current); + }, Decimal.from(base)); + } + + static from(value: string | number | bigint | Decimal): Decimal { + if (typeof value === 'string') { + return this.fromString(value); + } + if (typeof value === 'number') { + return this.fromNumber(value); + } + if (typeof value === 'bigint') { + return this.fromBigInt(value); + } + return value; + } + + static fromFraction(numerator: bigint, denominator: bigint): Decimal { + if (denominator < 0n) { + numerator = -numerator; + denominator = -denominator; + } + if (numerator < 0n) { + return this.fromFraction(-numerator, denominator).neg(); + } + const divisor = gcd(numerator, denominator); + numerator /= divisor; + denominator /= divisor; + let value = denominator; + let factor = 1n; + let exponent = 0; + while (value > 1 && (value % 2n === 0n)) { + value /= 2n; + factor *= 5n; + exponent -= 1; + } + while (value > 1 && (value % 5n === 0n)) { + value /= 5n; + factor *= 2n; + exponent -= 1; + } + assert(value === 1n, `Fraction ${numerator}/${denominator} cannot be represented with a fixed number of decimals`); + return new Decimal(numerator * factor, exponent); + } + + static fromBigInt(value: bigint): Decimal { + return new Decimal(value); + } + + static fromNumber(value: number): Decimal { + if (Number.isInteger(value)) { + return new Decimal(BigInt(value)); + } else { + return this.fromString(value.toString()); + } + } + + static fromString(string: string): Decimal { + if (/^(-?[0-9]+|0x[0-9a-f]+|0o[0-7]+|0b[01]+)$/i.test(string)) { + return this.fromBigInt(BigInt(string)); + } + const match = string.match(/^(-?\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i); + assert(match !== null, `Could not parse Decimal from string ${string}`); + let exponent = 0; + if (match[3] !== undefined) { + exponent = Number(match[3]); + } + const mantissa = (() => { + if (match[2] !== undefined) { + exponent -= match[2].length; + return match[1] + match[2]; + } else { + assert(match[1] !== undefined); + return match[1]; + } + })(); + return new Decimal(BigInt(mantissa), exponent); + } + + static max(first: Decimal | number | bigint | string, ...values: (Decimal | number | bigint | string)[]): Decimal { + return values.reduce((max: Decimal, value) => { + return max.lt(value) ? this.from(value) : max; + }, this.from(first)); + } + + static min(first: Decimal | number | bigint | string, ...values: (Decimal | number | bigint | string)[]): Decimal { + return values.reduce((min: Decimal, value) => { + return min.gt(value) ? this.from(value) : min; + }, this.from(first)); + } + + static mul(...values: (Decimal | number | bigint | string)[]): Decimal { + return values.reduce((previous: Decimal, current) => { + return previous.mul(current); + }, Decimal.one); + } + + static sub(base: Decimal, ...values: (Decimal | number | bigint | string)[]): Decimal { + return values.reduce((previous: Decimal, current) => { + return previous.sub(current); + }, base); + } + + static gcd(a: Decimal, b: Decimal): Decimal { + while (b.neq0()) { + const t = b; + b = a.mod(b); + a = t; + } + return a; + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5c04c92 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Quentin Adam + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..93fd225 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# decimal + +[![JSR](https://jsr.io/badges/@quentinadam/decimal)](https://jsr.io/@quentinadam/decimal) +[![CI](https://github.com/quentinadam/deno-decimal/actions/workflows/ci.yml/badge.svg)](https://github.com/quentinadam/deno-decimal/actions/workflows/ci.yml) + +A library for working with arbitrary precision decimal numbers. Numbers are represented by a mantissa (bigint) and an exponent (number). Division may fail if the resulting number is cannot be represented with a fixed number of decimals (like 1/3). + +## Usage + +```ts +import Decimal from '@quentinadam/decimal'; + +const a = Decimal.from(1.2); +const b = Decimal.from('3.4'); +const c = a.add(b); + +console.log(c.toString()); // prints 4.6 +``` diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..2cd0a91 --- /dev/null +++ b/deno.json @@ -0,0 +1,21 @@ +{ + "name": "@quentinadam/decimal", + "version": "0.1.0", + "exports": "./Decimal.ts", + "imports": { + "@quentinadam/assert": "jsr:@quentinadam/assert@^0.1.6" + }, + "publish": { + "exclude": [ + ".github/", + ".vscode/" + ] + }, + "fmt": { + "singleQuote": true, + "lineWidth": 120 + }, + "tasks": { + "check": "deno check **/*.ts" + } +}