There is only a certain string length allowed to convert parsed numbers to bigint.master
@@ -1,12 +1,17 @@ | |||||
import { enUS } from './systems'; | import { enUS } from './systems'; | ||||
import { StringifySystem } from './common'; | import { StringifySystem } from './common'; | ||||
import { exponentialToNumberString } from './exponent'; | |||||
import { exponentialToNumberString, extractExponentialComponents, numberToExponential } from './exponent'; | |||||
/** | /** | ||||
* Negative symbol. | * Negative symbol. | ||||
*/ | */ | ||||
const NEGATIVE_SYMBOL = '-' as const; | const NEGATIVE_SYMBOL = '-' as const; | ||||
/** | |||||
* Exponent delimiter. | |||||
*/ | |||||
const EXPONENT_DELIMITER = 'e' as const; | |||||
/** | /** | ||||
* Allowed value type for {@link stringify}. | * Allowed value type for {@link stringify}. | ||||
*/ | */ | ||||
@@ -104,12 +109,50 @@ export const parse = (value: string, options = {} as ParseOptions) => { | |||||
const stringValue = system.combineGroups(groups); | const stringValue = system.combineGroups(groups); | ||||
switch (type) { | switch (type) { | ||||
case 'number': | |||||
case 'number': { | |||||
// Precision might be lost here. Use bigint when not using fractional parts. | // 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); | 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; | break; | ||||
} | } | ||||
@@ -3,10 +3,7 @@ | |||||
*/ | */ | ||||
export type ValidValue = string | number | bigint; | 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 ".". | * The decimal point character to use. Defaults to ".". | ||||
*/ | */ | ||||
@@ -21,6 +18,21 @@ export interface NumberToExponentialOptions { | |||||
exponentDelimiter?: string; | 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. | * Default decimal point character. | ||||
*/ | */ | ||||
@@ -100,9 +112,9 @@ interface ExponentialComponents { | |||||
* @param options - Options to use when extracting components. | * @param options - Options to use when extracting components. | ||||
* @returns ExponentialComponents The extracted components. | * @returns ExponentialComponents The extracted components. | ||||
*/ | */ | ||||
const extractExponentialComponents = ( | |||||
export const extractExponentialComponents = ( | |||||
value: string, | value: string, | ||||
options = {} as NumberToExponentialOptions, | |||||
options = {} as BaseOptions, | |||||
): ExponentialComponents => { | ): ExponentialComponents => { | ||||
const { | const { | ||||
decimalPoint = DEFAULT_DECIMAL_POINT, | decimalPoint = DEFAULT_DECIMAL_POINT, | ||||
@@ -206,18 +218,47 @@ export const numberToExponential = ( | |||||
export const exponentialToNumberString = ( | export const exponentialToNumberString = ( | ||||
exp: string, | 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 { integer, fractional, exponent } = extractExponentialComponents(exp, options); | ||||
const currentDecimalPointIndex = integer.length; | const currentDecimalPointIndex = integer.length; | ||||
const newDecimalPointIndex = currentDecimalPointIndex + Number(exponent); | 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 fractionalDigits = fractional.length > 0 ? fractional : '0'; | ||||
const significantDigits = `${integer}${fractionalDigits}`; | 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, ''); | const newFractional = significantDigits.slice(newDecimalPointIndex).replace(/0+$/g, ''); | ||||
if (newFractional.length === 0) { | if (newFractional.length === 0) { | ||||
return newInteger; | 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}`; | return `${newInteger}${decimalPoint}${newFractional}`; | ||||
}; | }; |
@@ -121,6 +121,7 @@ const doParseGroupName = (result: DoParseState): DoParseState => { | |||||
if (result.groupNameCurrent.startsWith(`${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}`)) { | if (result.groupNameCurrent.startsWith(`${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}`)) { | ||||
// short millia | // short millia | ||||
// FIXME | |||||
const matchedMilliaArray = result.groupNameCurrent.match(/^\d+/); | const matchedMilliaArray = result.groupNameCurrent.match(/^\d+/); | ||||
if (!matchedMilliaArray) { | if (!matchedMilliaArray) { | ||||
throw new InvalidTokenError(result.groupNameCurrent); | throw new InvalidTokenError(result.groupNameCurrent); | ||||
@@ -54,9 +54,13 @@ describe('numberToExponential', () => { | |||||
it('converts "1e+100" to 1e+100', () => { | it('converts "1e+100" to 1e+100', () => { | ||||
expect(numberToExponential('1e+100')).toBe('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', () => { | it('converts 1e+0 to 1', () => { | ||||
expect(exponentialToNumberString('1e+0')).toBe('1'); | expect(exponentialToNumberString('1e+0')).toBe('1'); | ||||
}); | }); | ||||
@@ -50,6 +50,10 @@ | |||||
box-sizing: border-box; | box-sizing: border-box; | ||||
} | } | ||||
.main-form > div > textarea:invalid { | |||||
color: red; | |||||
} | |||||
.checkbox > input { | .checkbox > input { | ||||
position: absolute; | position: absolute; | ||||
left: -999999px; | left: -999999px; | ||||
@@ -97,7 +101,10 @@ | |||||
</label><label class="checkbox"> | </label><label class="checkbox"> | ||||
<input type="checkbox" name="addTensDashes" checked> | <input type="checkbox" name="addTensDashes" checked> | ||||
<span>Add dashes to tens</span> | <span>Add dashes to tens</span> | ||||
</label> | |||||
</label><!--<label class="checkbox"> | |||||
<input type="groupDigits" name="groupDigits" checked> | |||||
<span>Group digits</span> | |||||
</label>--> | |||||
</div> | </div> | ||||
</fieldset> | </fieldset> | ||||
<div> | <div> | ||||
@@ -127,19 +134,31 @@ | |||||
}; | }; | ||||
numberInput.addEventListener('input', (e) => { | numberInput.addEventListener('input', (e) => { | ||||
e.currentTarget.setCustomValidity(''); | |||||
nameInput.value = ''; | nameInput.value = ''; | ||||
if (e.currentTarget.value.trim() === '') { | if (e.currentTarget.value.trim() === '') { | ||||
return; | 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) => { | nameInput.addEventListener('input', (e) => { | ||||
e.currentTarget.setCustomValidity(''); | |||||
numberInput.value = ''; | numberInput.value = ''; | ||||
if (e.currentTarget.value.trim() === '') { | if (e.currentTarget.value.trim() === '') { | ||||
return; | 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) => { | addTensDashesCheckbox.addEventListener('change', (e) => { | ||||