There is only a certain string length allowed to convert parsed numbers to bigint.master
@@ -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; | |||
} | |||
@@ -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}`; | |||
}; |
@@ -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); | |||
@@ -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'); | |||
}); | |||
@@ -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 @@ | |||
</label><label class="checkbox"> | |||
<input type="checkbox" name="addTensDashes" checked> | |||
<span>Add dashes to tens</span> | |||
</label> | |||
</label><!--<label class="checkbox"> | |||
<input type="groupDigits" name="groupDigits" checked> | |||
<span>Group digits</span> | |||
</label>--> | |||
</div> | |||
</fieldset> | |||
<div> | |||
@@ -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) => { | |||