From 545d0a54aa2a7f1e06d755a11520d0fcf76a3a15 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Wed, 16 Aug 2023 14:34:18 +0800 Subject: [PATCH] Add bailout for conversion to bigint There is only a certain string length allowed to convert parsed numbers to bigint. --- packages/core/src/converter.ts | 53 +++++++++++++++++++-- packages/core/src/exponent.ts | 59 ++++++++++++++++++++---- packages/core/src/systems/en-US/parse.ts | 1 + packages/core/test/exponent.test.ts | 6 ++- packages/example-web/index.html | 25 ++++++++-- 5 files changed, 126 insertions(+), 18 deletions(-) diff --git a/packages/core/src/converter.ts b/packages/core/src/converter.ts index 72a53e4..6e16a18 100644 --- a/packages/core/src/converter.ts +++ b/packages/core/src/converter.ts @@ -1,12 +1,17 @@ import { enUS } from './systems'; import { StringifySystem } from './common'; -import { exponentialToNumberString } from './exponent'; +import { exponentialToNumberString, extractExponentialComponents, numberToExponential } from './exponent'; /** * Negative symbol. */ const NEGATIVE_SYMBOL = '-' as const; +/** + * Exponent delimiter. + */ +const EXPONENT_DELIMITER = 'e' as const; + /** * Allowed value type for {@link stringify}. */ @@ -104,12 +109,50 @@ export const parse = (value: string, options = {} as ParseOptions) => { const stringValue = system.combineGroups(groups); switch (type) { - case 'number': + case 'number': { // Precision might be lost here. Use bigint when not using fractional parts. + if (stringValue.includes(EXPONENT_DELIMITER)) { + const { exponent, integer } = extractExponentialComponents(stringValue); + const exponentValue = Number(exponent); + const integerValue = Number(integer); + + const [maxSafeIntegerSignificand, maxSafeIntegerExponent] = Number.MAX_SAFE_INTEGER.toExponential().split('e'); + if ( + exponentValue >= Number(maxSafeIntegerExponent) + && integerValue >= Math.floor(Number(maxSafeIntegerSignificand)) + ) { + // greater than Number.MAX_SAFE_INTEGER + const logger = console; + logger.warn(`Value too large to be produced as number: ${value}`); + logger.warn('Falling back to string...'); + return stringValue; + } + + const [epsilonSignificand, epsilonExponent] = Number.EPSILON.toExponential().split('e'); + if ( + exponentValue <= Number(epsilonExponent) + && integerValue <= Math.floor(Number(epsilonSignificand)) + ) { + // smaller than Number.EPSILON + const logger = console; + logger.warn(`Value too small to be produced as number: ${value}`); + logger.warn('Falling back to string...'); + return stringValue; + } + } return Number(stringValue); - case 'bigint': - return BigInt(exponentialToNumberString(stringValue)); - default: + } case 'bigint': { + const normalizedNumberString = exponentialToNumberString(numberToExponential(stringValue)); + try { + return BigInt(normalizedNumberString); + } catch { + const logger = console; + logger.warn(`Value too long to be produced as bigint: ${value}`); + logger.warn('Falling back to string...'); + } + + return stringValue; + } default: break; } diff --git a/packages/core/src/exponent.ts b/packages/core/src/exponent.ts index b9dffb7..c8b4153 100644 --- a/packages/core/src/exponent.ts +++ b/packages/core/src/exponent.ts @@ -3,10 +3,7 @@ */ export type ValidValue = string | number | bigint; -/** - * Options to use when converting a number to exponential notation. - */ -export interface NumberToExponentialOptions { +interface BaseOptions { /** * The decimal point character to use. Defaults to ".". */ @@ -21,6 +18,21 @@ export interface NumberToExponentialOptions { exponentDelimiter?: string; } +/** + * Options to use when converting a number to exponential notation. + */ +export type NumberToExponentialOptions = BaseOptions; + +/** + * Options to use when converting exponential notation to a number string. + */ +export interface ExponentialToNumberStringOptions extends BaseOptions { + /** + * The maximum length to represent the return value else the input will be preserved. + */ + maxLength?: number; +} + /** * Default decimal point character. */ @@ -100,9 +112,9 @@ interface ExponentialComponents { * @param options - Options to use when extracting components. * @returns ExponentialComponents The extracted components. */ -const extractExponentialComponents = ( +export const extractExponentialComponents = ( value: string, - options = {} as NumberToExponentialOptions, + options = {} as BaseOptions, ): ExponentialComponents => { const { decimalPoint = DEFAULT_DECIMAL_POINT, @@ -206,18 +218,47 @@ export const numberToExponential = ( export const exponentialToNumberString = ( exp: string, - options = {} as NumberToExponentialOptions, + options = {} as ExponentialToNumberStringOptions, ) => { - const { decimalPoint = DEFAULT_DECIMAL_POINT } = options; + const { + decimalPoint = DEFAULT_DECIMAL_POINT, + maxLength = Number.MAX_SAFE_INTEGER, + } = options; const { integer, fractional, exponent } = extractExponentialComponents(exp, options); const currentDecimalPointIndex = integer.length; const newDecimalPointIndex = currentDecimalPointIndex + Number(exponent); + if (newDecimalPointIndex > maxLength) { + // Integer part overflow + // + // Important to bail out as early as possible. + throw new RangeError('Could not represent number in string form.'); + } const fractionalDigits = fractional.length > 0 ? fractional : '0'; const significantDigits = `${integer}${fractionalDigits}`; - const newInteger = significantDigits.slice(0, newDecimalPointIndex).padEnd(newDecimalPointIndex, '0'); + if (significantDigits.length > maxLength) { + // Digits overflow + // + // Should we choose to truncate instead of throw an exception? + // Quite tricky when only the fractional part overflows. + throw new RangeError('Could not represent number in string form.'); + } + let newInteger: string; + try { + newInteger = significantDigits.slice(0, newDecimalPointIndex) + .padEnd(newDecimalPointIndex, '0'); + } catch { + // Error in padEnd(), shadow it. + throw new RangeError('Could not represent number in string form.'); + } const newFractional = significantDigits.slice(newDecimalPointIndex).replace(/0+$/g, ''); if (newFractional.length === 0) { return newInteger; } + if (newInteger.length + decimalPoint.length + newFractional.length > maxLength) { + // Fractional part overflow + // + // Final length check. + throw new RangeError('Could not represent number in string form.'); + } return `${newInteger}${decimalPoint}${newFractional}`; }; diff --git a/packages/core/src/systems/en-US/parse.ts b/packages/core/src/systems/en-US/parse.ts index 00d6fdc..506f47c 100644 --- a/packages/core/src/systems/en-US/parse.ts +++ b/packages/core/src/systems/en-US/parse.ts @@ -121,6 +121,7 @@ const doParseGroupName = (result: DoParseState): DoParseState => { if (result.groupNameCurrent.startsWith(`${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}`)) { // short millia + // FIXME const matchedMilliaArray = result.groupNameCurrent.match(/^\d+/); if (!matchedMilliaArray) { throw new InvalidTokenError(result.groupNameCurrent); diff --git a/packages/core/test/exponent.test.ts b/packages/core/test/exponent.test.ts index 271563e..2f4fa27 100644 --- a/packages/core/test/exponent.test.ts +++ b/packages/core/test/exponent.test.ts @@ -54,9 +54,13 @@ describe('numberToExponential', () => { it('converts "1e+100" to 1e+100', () => { expect(numberToExponential('1e+100')).toBe('1e+100'); }); + + it('converts "12.3e+10" to 1.23e+11', () => { + expect(numberToExponential('12.3e+10')).toBe('1.23e+11'); + }); }); -describe.only('exponentialToNumberString', () => { +describe('exponentialToNumberString', () => { it('converts 1e+0 to 1', () => { expect(exponentialToNumberString('1e+0')).toBe('1'); }); diff --git a/packages/example-web/index.html b/packages/example-web/index.html index 8bf3f3d..24277ac 100644 --- a/packages/example-web/index.html +++ b/packages/example-web/index.html @@ -50,6 +50,10 @@ box-sizing: border-box; } + .main-form > div > textarea:invalid { + color: red; + } + .checkbox > input { position: absolute; left: -999999px; @@ -97,7 +101,10 @@ +
@@ -127,19 +134,31 @@ }; numberInput.addEventListener('input', (e) => { + e.currentTarget.setCustomValidity(''); nameInput.value = ''; if (e.currentTarget.value.trim() === '') { return; } - nameInput.value = stringify(e.currentTarget.value, options.stringify); + try { + nameInput.value = stringify(e.currentTarget.value, options.stringify); + } catch { + e.currentTarget.setCustomValidity('Invalid number.'); + } }); nameInput.addEventListener('input', (e) => { + e.currentTarget.setCustomValidity(''); numberInput.value = ''; if (e.currentTarget.value.trim() === '') { return; } - numberInput.value = parse(e.currentTarget.value, options.parse).toString(); + try { + numberInput.value = parse(e.currentTarget.value, options.parse) + .toString(); + // TODO group digits function from system. + } catch { + e.currentTarget.setCustomValidity('Invalid name.'); + } }); addTensDashesCheckbox.addEventListener('change', (e) => {