diff --git a/README.md b/README.md new file mode 100644 index 0000000..57b0230 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# numerica + +Get the name of a number, even if it's stupidly big. + +## References + +* [How high can you count?](http://www.isthe.com/chongo/tech/math/number/howhigh.html) diff --git a/packages/core/package.json b/packages/core/package.json index 4dde4cd..78b8fcd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,10 +11,10 @@ "license": "UNLICENSED", "keywords": [ "number", - "language", - "conversion", - "name", - "words" + "language", + "conversion", + "name", + "words" ], "devDependencies": { "@types/node": "^18.14.1", diff --git a/packages/core/src/common.ts b/packages/core/src/common.ts index 545f946..f73458c 100644 --- a/packages/core/src/common.ts +++ b/packages/core/src/common.ts @@ -1,15 +1,88 @@ +/** + * Group digits. + */ type GroupDigits = string; +/** + * Group place. + */ type GroupPlace = number; +/** + * Group of digits and its place. + * + * The place refers to the order which the digits are grouped, e.g. for a number like + * + * 1,234,567 + * + * The groups would be: + * + * ['001', 2] + * ['234', 1] + * ['567', 0] + * + * Note that groups do not necessarily have the same length of digits, such in the case of + * South Asian numbering system: + * + * 1,00,00,000 + * + * The groups would be: + * + * ['01', 3] + * ['00', 2] + * ['00', 1] + * ['000', 0] + */ export type Group = [GroupDigits, GroupPlace]; +/** + * System for stringifying and parsing numbers. + */ export interface StringifySystem { + /** + * Creates a negative string. + * @param s - The string to make negative. + */ makeNegative: (s: string) => string; + /** + * Creates a group string. + * @param group - The group digits. + * @param place - The group place. + * @param options - Options to use when creating the group. + */ makeGroup: (group: string, place?: GroupPlace, options?: Record) => string; + /** + * Groups a string. + * @param value - The string to group. + */ group: (value: string) => Group[]; + /** + * Finalizes a string. + * @param tokens - The tokens to finalize. + */ finalize: (tokens: string[]) => string; + /** + * Tokenizes a string. + * @param value - The string to tokenize. + */ tokenize: (value: string) => string[]; + /** + * Parses groups from a string. + * @param value - The string to parse groups from. + */ parseGroups: (value: string[]) => Group[]; + /** + * Combines groups into a string. + * @param value - The groups to combine. + */ combineGroups: (value: Group[]) => string; } + +/** + * Error thrown when an invalid token is encountered. + */ +export class InvalidTokenError extends Error { + constructor(token: string) { + super(`Invalid token: ${token}`); + } +} diff --git a/packages/core/src/converter.ts b/packages/core/src/converter.ts index f4ccb47..4522203 100644 --- a/packages/core/src/converter.ts +++ b/packages/core/src/converter.ts @@ -1,30 +1,72 @@ import { enUS } from './systems'; import { StringifySystem } from './common'; -type StringifyValue = string | number | bigint; +/** + * Negative symbol. + */ +const NEGATIVE_SYMBOL = '-' as const; +/** + * Allowed value type for {@link stringify}. + */ +type AllowedValue = string | number | bigint; + +/** + * Array of allowed types for {@link parse}. + */ +const ALLOWED_PARSE_RESULT_TYPES = [ + 'string', + 'number', + 'bigint', +] as const; + +/** + * Allowed type for {@link parse}. + */ +type ParseResult = typeof ALLOWED_PARSE_RESULT_TYPES[number]; + +/** + * Options to use when converting a value to a string. + */ export interface StringifyOptions { + /** + * The system to use when converting a value to a string. + * + * Defaults to en-US (American short count). + */ system?: StringifySystem; + /** + * Options to use when making a group. This is used to override the default options for a group. + */ makeGroupOptions?: Record; } +/** + * Converts a numeric value to its name. + * @param value - The value to convert. + * @param options - Options to use when converting a value to its name. + * @returns The name of the value. + */ export const stringify = ( - valueRaw: StringifyValue, + value: AllowedValue, options = {} as StringifyOptions, ): string => { - if (!(['bigint', 'number', 'string'].includes(typeof (valueRaw as unknown)))) { - throw new TypeError('value must be a string, number, or bigint'); + if (!( + (ALLOWED_PARSE_RESULT_TYPES as unknown as string[]) + .includes(typeof (value as unknown)) + )) { + throw new TypeError(`Value must be a string, number, or bigint. Received: ${typeof value}`); } - const value = valueRaw.toString().replace(/\s/g, ''); + const valueStr = value.toString().replace(/\s/g, ''); const { system = enUS, makeGroupOptions } = options; - if (value.startsWith('-')) { - return system.makeNegative(stringify(value.slice(1), options)); + if (valueStr.startsWith(NEGATIVE_SYMBOL)) { + return system.makeNegative(stringify(valueStr.slice(NEGATIVE_SYMBOL.length), options)); } const groups = system - .group(value) + .group(valueStr) .map(([group, place]) => ( system.makeGroup(group, place, makeGroupOptions) )); @@ -32,13 +74,28 @@ export const stringify = ( return system.finalize(groups); }; -type ParseType = 'string' | 'number' | 'bigint'; - +/** + * Options to use when parsing a name of a number to its numeric equivalent. + */ export interface ParseOptions { + /** + * The system to use when parsing a name of a number to its numeric equivalent. + * + * Defaults to en-US (American short count). + */ system?: StringifySystem; - type?: ParseType; + /** + * The type to parse the value as. + */ + type?: ParseResult; } +/** + * Parses a name of a number to its numeric equivalent. + * @param value - The value to parse. + * @param options - Options to use when parsing a name of a number to its numeric equivalent. + * @returns The numeric equivalent of the value. + */ export const parse = (value: string, options = {} as ParseOptions) => { const { system = enUS, type = 'string' } = options; @@ -48,6 +105,7 @@ export const parse = (value: string, options = {} as ParseOptions) => { switch (type) { case 'number': + // Precision might be lost here. Use bigint when not using fractional parts. return Number(stringValue); case 'bigint': return BigInt(stringValue); diff --git a/packages/core/src/exponent.ts b/packages/core/src/exponent.ts index a4dad5f..54398e9 100644 --- a/packages/core/src/exponent.ts +++ b/packages/core/src/exponent.ts @@ -16,24 +16,92 @@ export interface NumberToExponentialOptions { */ groupingSymbol?: string; /** - * Exponent character to use. Defaults to "e". + * The exponent character to use. Defaults to "e". */ exponentDelimiter?: string; } +/** + * Default decimal point character. + */ +const DEFAULT_DECIMAL_POINT = '.' as const; + +/** + * Default grouping symbol. + */ +const DEFAULT_GROUPING_SYMBOL = ',' as const; + +/** + * Default exponent character. + */ +const DEFAULT_EXPONENT_DELIMITER = 'e' as const; + +/** + * Default positive symbol. + */ +const DEFAULT_POSITIVE_SYMBOL = '+' as const; + +/** + * Default negative symbol. + */ +const DEFAULT_NEGATIVE_SYMBOL = '-' as const; + +/** + * Forces a value to have a decimal point. + * @param value - The value to force a decimal point on. + * @param decimalPoint - The decimal point character to use. + * @returns The value with a decimal point. + */ +const forceDecimalPoint = (value: string, decimalPoint: string) => ( + value.includes(decimalPoint) + ? value + : `${value}${decimalPoint}0` +); + +/** + * Error thrown when a value is not in exponential notation. + */ +export class InvalidFormatError extends TypeError { + constructor(value: string) { + super(`Value must be in exponential notation. Received: ${value}`); + } +} + +/** + * Error thrown when a value is not a string, number, or bigint. + */ +export class InvalidValueTypeError extends TypeError { + constructor(value: unknown) { + super(`Value must be a string, number, or bigint. Received: ${typeof value}`); + } +} + +/** + * Forces a number to have a sign. + * @param value - The value to force a sign on. + * @returns The value with a sign. + */ +const forceNumberSign = (value: number | bigint) => { + const isExponentNegative = value < 0; + const exponentValueAbs = isExponentNegative ? -value : value; + const exponentSign = isExponentNegative ? DEFAULT_NEGATIVE_SYMBOL : DEFAULT_POSITIVE_SYMBOL; + return `${exponentSign}${exponentValueAbs}`; +}; + /** * Extracts the integer, fractional, and exponent components of a string in exponential notation. * @param value - The string value to extract components from. * @param options - Options to use when extracting components. + * @returns The extracted components. */ export const extractExponentialComponents = ( value: string, options = {} as NumberToExponentialOptions, ) => { const { - decimalPoint = '.', - groupingSymbol = ',', - exponentDelimiter = 'e', + decimalPoint = DEFAULT_DECIMAL_POINT, + groupingSymbol = DEFAULT_GROUPING_SYMBOL, + exponentDelimiter = DEFAULT_EXPONENT_DELIMITER, } = options; const valueWithoutGroupingSymbols = value.replace(new RegExp(`${groupingSymbol}`, 'g'), ''); const exponentDelimiterIndex = valueWithoutGroupingSymbols.indexOf(exponentDelimiter); @@ -41,21 +109,20 @@ export const extractExponentialComponents = ( if (exponentDelimiterIndex < 0) { // We force the value to have decimal point so that we can extract the integer and fractional // components. - const stringValueWithDecimal = valueWithoutGroupingSymbols.includes(decimalPoint) - ? valueWithoutGroupingSymbols - : `${valueWithoutGroupingSymbols}${decimalPoint}0`; + const stringValueWithDecimal = forceDecimalPoint(valueWithoutGroupingSymbols, decimalPoint); const [integerRaw, fractionalRaw] = stringValueWithDecimal.split(decimalPoint); const integer = integerRaw.replace(/^0+/g, ''); + const fractional = fractionalRaw.replace(/0+$/g, ''); const exponentValue = BigInt(integer.length - 1); return { integer, - exponent: exponentValue < 0 ? exponentValue.toString() : `+${exponentValue}`, - fractional: fractionalRaw.replace(/0+$/g, ''), + exponent: forceNumberSign(exponentValue), + fractional, }; } if (exponentDelimiterIndex !== valueWithoutGroupingSymbols.lastIndexOf(exponentDelimiter)) { - throw new TypeError('Value must not contain more than one exponent character'); + throw new InvalidFormatError(value); } const [base, exponentRaw] = valueWithoutGroupingSymbols.split(exponentDelimiter); @@ -67,7 +134,7 @@ export const extractExponentialComponents = ( const exponentValue = BigInt(exponentRaw) + BigInt(extraIntegerDigits.length); return { integer, - exponent: exponentValue < 0 ? exponentValue.toString() : `+${exponentValue}`, + exponent: forceNumberSign(exponentValue), fractional, }; }; @@ -76,32 +143,35 @@ export const extractExponentialComponents = ( * Converts a numeric value to a string in exponential notation. Supports numbers of all types. * @param value - The value to convert. * @param options - Options to use when extracting components. + * @returns The value in exponential notation. */ export const numberToExponential = ( value: ValidValue, options = {} as NumberToExponentialOptions, ): string => { - const stringValueRaw = value as unknown; + const valueRaw = value as unknown; - if (typeof stringValueRaw === 'bigint' || typeof stringValueRaw === 'number') { - return numberToExponential(stringValueRaw.toString(), options); + if (typeof valueRaw === 'bigint' || typeof valueRaw === 'number') { + return numberToExponential(valueRaw.toString(), options); } - if (typeof stringValueRaw !== 'string') { - throw new TypeError(`Value must be a string, number, or bigint. Received: ${typeof stringValueRaw}`); + if (typeof valueRaw !== 'string') { + throw new InvalidValueTypeError(valueRaw); } - if (stringValueRaw.startsWith('-')) { - return `-${numberToExponential(stringValueRaw.slice(1), options)}}`; + if (valueRaw.startsWith(DEFAULT_NEGATIVE_SYMBOL)) { + return `${DEFAULT_NEGATIVE_SYMBOL}${numberToExponential(valueRaw.slice(DEFAULT_NEGATIVE_SYMBOL.length), options)}}`; } const { - decimalPoint = '.', - groupingSymbol = ',', - exponentDelimiter = 'e', + decimalPoint = DEFAULT_DECIMAL_POINT, + groupingSymbol = DEFAULT_GROUPING_SYMBOL, + exponentDelimiter = DEFAULT_EXPONENT_DELIMITER, } = options; - const stringValue = stringValueRaw + // Remove invalid characters. + // We also remove the grouping symbols in case they come from human input. + const stringValue = valueRaw .replace(new RegExp(`${groupingSymbol}`, 'g'), '') .toLowerCase() .replace(/\s/g, ''); @@ -115,7 +185,7 @@ export const numberToExponential = ( const significantDigits = `${integer}${fractional}`; if (significantDigits.length === 0) { // We copy the behavior from `Number.prototype.toExponential` here. - return `0${exponentDelimiter}+0`; + return `0${exponentDelimiter}${DEFAULT_POSITIVE_SYMBOL}0`; } const significandInteger = significantDigits[0]; diff --git a/packages/core/src/systems/en-US.ts b/packages/core/src/systems/en-US.ts index fb36361..a6eea5f 100644 --- a/packages/core/src/systems/en-US.ts +++ b/packages/core/src/systems/en-US.ts @@ -1,14 +1,29 @@ // noinspection SpellCheckingInspection -import { Group } from '../common'; +import { Group, InvalidTokenError } from '../common'; import { numberToExponential } from '../exponent'; -const DECIMAL_POINT = '.'; +const DECIMAL_POINT = '.' as const; -const GROUPING_SYMBOL = ','; +const GROUPING_SYMBOL = ',' as const; -const NEGATIVE = 'negative'; +const NEGATIVE = 'negative' as const; +const NEGATIVE_SYMBOL = '-' as const; + +const POSITIVE_SYMBOL = '+' as const; + +const SHORT_MILLIA_DELIMITER = '^' as const; + +const EXPONENT_DELIMITER = 'e' as const; + +const EMPTY_GROUP_DIGITS = '000' as const; + +const EMPTY_PLACE: Group = [EMPTY_GROUP_DIGITS, 0]; + +/** + * Ones number names. + */ const ONES = [ 'zero', 'one', @@ -24,6 +39,9 @@ const ONES = [ type OnesName = typeof ONES[number]; +/** + * Ten plus ones number names. + */ const TEN_PLUS_ONES = [ 'ten', 'eleven', @@ -39,6 +57,9 @@ const TEN_PLUS_ONES = [ type TenPlusOnesName = typeof TEN_PLUS_ONES[number]; +/** + * Tens number names. + */ const TENS = [ 'zero', TEN_PLUS_ONES[0], @@ -54,14 +75,23 @@ const TENS = [ type TensName = typeof TENS[number]; +/** + * Hundreds name. + */ const HUNDRED = 'hundred' as const; +/** + * Thousands name. + */ const THOUSAND = 'thousand' as const; // const ILLION_ORDINAL_SUFFIX = 'illionth' as const; // const THOUSAND_ORDINAL = 'thousandth' as const; +/** + * Special millions name. + */ const MILLIONS_SPECIAL_PREFIXES = [ '', 'm', @@ -75,8 +105,11 @@ const MILLIONS_SPECIAL_PREFIXES = [ 'non', ] as const; -type MillionsSpecialPrefix = typeof MILLIONS_SPECIAL_PREFIXES[number]; +type MillionsSpecialPrefix = Exclude; +/** + * Millions name. + */ const MILLIONS_PREFIXES = [ '', 'un', @@ -90,8 +123,11 @@ const MILLIONS_PREFIXES = [ 'novem', ] as const; -type MillionsPrefix = typeof MILLIONS_PREFIXES[number]; +type MillionsPrefix = Exclude; +/** + * Decillions name. + */ const DECILLIONS_PREFIXES = [ '', 'dec', @@ -105,8 +141,11 @@ const DECILLIONS_PREFIXES = [ 'nonagin', ] as const; -type DecillionsPrefix = typeof DECILLIONS_PREFIXES[number]; +type DecillionsPrefix = Exclude; +/** + * Centillions name. + */ const CENTILLIONS_PREFIXES = [ '', 'cen', @@ -120,12 +159,24 @@ const CENTILLIONS_PREFIXES = [ 'nongen', ] as const; -type CentillionsPrefix = typeof CENTILLIONS_PREFIXES[number]; +type CentillionsPrefix = Exclude; +/** + * Prefix for millia- number names. + */ const MILLIA_PREFIX = 'millia' as const; +/** + * Suffix for -illion number names. + */ const ILLION_SUFFIX = 'illion' as const; +/** + * Builds a name for numbers in tens and ones. + * @param tens - Tens digit. + * @param ones - Ones digit. + * @returns The name for the number. + */ const makeTensName = (tens: number, ones: number) => { if (tens === 0) { return ONES[ones]; @@ -142,6 +193,13 @@ const makeTensName = (tens: number, ones: number) => { return `${TENS[tens] as Exclude} ${ONES[ones] as Exclude}` as const; }; +/** + * Builds a name for numbers in hundreds, tens, and ones. + * @param hundreds - Hundreds digit. + * @param tens - Tens digit. + * @param ones - Ones digit. + * @returns The name for the number. + */ const makeHundredsName = (hundreds: number, tens: number, ones: number) => { if (hundreds === 0) { return makeTensName(tens, ones); @@ -154,24 +212,45 @@ const makeHundredsName = (hundreds: number, tens: number, ones: number) => { return `${ONES[hundreds]} ${HUNDRED} ${makeTensName(tens, ones)}` as const; }; +/** + * Builds a name for numbers in the millions. + * @param millions - Millions digit. + * @param milliaCount - Number of millia- groups. + * @returns The millions prefix. + */ const makeMillionsPrefix = (millions: number, milliaCount: number) => { if (milliaCount > 0) { - return MILLIONS_PREFIXES[millions] as Exclude; + return MILLIONS_PREFIXES[millions] as MillionsPrefix; } - return MILLIONS_SPECIAL_PREFIXES[millions] as Exclude; + return MILLIONS_SPECIAL_PREFIXES[millions] as MillionsSpecialPrefix; }; +/** + * Builds a name for numbers in the decillions. + * @param decillions - Decillions digit. + * @param millions - Millions digit. + * @param milliaCount - Number of millia- groups. + * @returns The decillions prefix. + */ const makeDecillionsPrefix = (decillions: number, millions: number, milliaCount: number) => { if (decillions === 0) { return makeMillionsPrefix(millions, milliaCount); } - const onesPrefix = MILLIONS_PREFIXES[millions] as Exclude; - const tensName = DECILLIONS_PREFIXES[decillions] as Exclude; + const onesPrefix = MILLIONS_PREFIXES[millions] as MillionsPrefix; + const tensName = DECILLIONS_PREFIXES[decillions] as DecillionsPrefix; return `${onesPrefix}${tensName}` as const; }; +/** + * Builds a name for numbers in the centillions. + * @param centillions - Centillions digit. + * @param decillions - Decillions digit. + * @param millions - Millions digit. + * @param milliaCount - Number of millia- groups. + * @returns The centillions prefix. + */ const makeCentillionsPrefix = ( centillions: number, decillions: number, @@ -182,9 +261,9 @@ const makeCentillionsPrefix = ( return makeDecillionsPrefix(decillions, millions, milliaCount); } - const onesPrefix = MILLIONS_PREFIXES[millions] as Exclude; - const tensName = DECILLIONS_PREFIXES[decillions] as Exclude; - const hundredsName = CENTILLIONS_PREFIXES[centillions] as Exclude; + const onesPrefix = MILLIONS_PREFIXES[millions] as MillionsPrefix; + const tensName = DECILLIONS_PREFIXES[decillions] as DecillionsPrefix; + const hundredsName = CENTILLIONS_PREFIXES[centillions] as CentillionsPrefix; return `${hundredsName}${onesPrefix}${decillions > 0 ? tensName : ''}` as const; }; @@ -220,21 +299,21 @@ const getGroupName = (place: number, shortenMillia: boolean) => { }, [], ) - .map(([group, groupPlace]) => [group.padStart(3, '0'), groupPlace] as const) - .filter(([group]) => group !== '000') - .map(([group, groupPlace]) => { - const [hundreds, tens, ones] = group.split('').map(Number); + .map(([groupDigits, groupPlace]) => [groupDigits.padStart(3, '0'), groupPlace] as const) + .filter(([groupDigits]) => groupDigits !== EMPTY_GROUP_DIGITS) + .map(([groupDigits, groupPlace]) => { + const [hundreds, tens, ones] = groupDigits.split('').map(Number); if (groupPlace < 1) { return makeCentillionsPrefix(hundreds, tens, ones, groupPlace); } const milliaSuffix = ( shortenMillia && groupPlace > 1 - ? `${MILLIA_PREFIX}^${groupPlace}` + ? `${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}${groupPlace}` : MILLIA_PREFIX.repeat(groupPlace) ); - if (group === '001') { + if (groupDigits === '001') { return milliaSuffix; } @@ -276,22 +355,21 @@ export const makeGroup = ( * @param value - The number string to group. */ export const group = (value: string): Group[] => { - const exponentDelimiter = 'e'; const [significand, exponentString] = numberToExponential( value, { decimalPoint: DECIMAL_POINT, groupingSymbol: GROUPING_SYMBOL, - exponentDelimiter, + exponentDelimiter: EXPONENT_DELIMITER, }, ) - .split(exponentDelimiter); + .split(EXPONENT_DELIMITER); const exponent = Number(exponentString); const significantDigits = significand.replace(DECIMAL_POINT, ''); return significantDigits.split('').reduce( (acc, c, i) => { const currentPlace = Math.floor((exponent - i) / 3); - const lastGroup = acc.at(-1) ?? ['000', currentPlace]; + const lastGroup = acc.at(-1) ?? [EMPTY_GROUP_DIGITS, currentPlace]; const currentPlaceInGroup = 2 - ((exponent - i) % 3); if (lastGroup[1] === currentPlace) { const lastGroupDigits = lastGroup[0].split(''); @@ -330,95 +408,154 @@ export const tokenize = (stringValue: string) => ( stringValue.split(' ').filter((maybeToken) => maybeToken.length > 0) ); -const FINAL_TOKEN = ''; +const FINAL_TOKEN = '' as const; -const getGroupFromGroupName = (groupName: string) => { - if (groupName === THOUSAND) { - return 1; - } - - const groupNameBase = groupName.replace(ILLION_SUFFIX, ''); - const specialMillions = MILLIONS_SPECIAL_PREFIXES.findIndex((p) => groupNameBase === p); +interface DoParseState { + groupNameCurrent: string; + millias: number[]; + milliaIndex: number; + done: boolean; +} - if (specialMillions > -1) { - return 1 + specialMillions; +const doParseGroupName = (result: DoParseState): DoParseState => { + if (result.groupNameCurrent.length < 1) { + return { + ...result, + done: true, + }; } - let groupNameCurrent = groupNameBase; + if (result.groupNameCurrent === 't') { + // If the current group name is "t", then we're done. + // We use the -t- affix to attach the group prefix to the -illion suffix, except for decillion. + return { + ...result, + done: true, + }; + } - const millias = [0]; - let milliaIndex = 0; + const centillions = CENTILLIONS_PREFIXES.findIndex((p) => ( + p.length > 0 && result.groupNameCurrent.startsWith(p) + )); + if (centillions > -1) { + return { + milliaIndex: 0, + millias: result.millias.map((m, i) => ( + i === 0 + ? m + (centillions * 100) + : m + )), + groupNameCurrent: result.groupNameCurrent.slice( + CENTILLIONS_PREFIXES[centillions].length, + ), + done: false, + }; + } - while (groupNameCurrent.length > 0) { - if (groupNameCurrent === 't') { - break; - } + const decillions = DECILLIONS_PREFIXES.findIndex((p) => ( + p.length > 0 && result.groupNameCurrent.startsWith(p) + )); + if (decillions > -1) { + return { + milliaIndex: 0, + millias: result.millias.map((m, i) => ( + i === 0 + ? m + (decillions * 10) + : m + )), + groupNameCurrent: result.groupNameCurrent.slice( + DECILLIONS_PREFIXES[decillions].length, + ), + done: false, + }; + } - const centillions = CENTILLIONS_PREFIXES.findIndex((p) => ( - p.length > 0 && groupNameCurrent.startsWith(p) - )); + const millions = MILLIONS_PREFIXES.findIndex((p) => ( + p.length > 0 && result.groupNameCurrent.startsWith(p) + )); + if (millions > -1) { + return { + milliaIndex: 0, + millias: result.millias.map((m, i) => ( + i === 0 + ? m + millions + : m + )), + groupNameCurrent: result.groupNameCurrent.slice( + MILLIONS_PREFIXES[millions].length, + ), + done: false, + }; + } - if (centillions > -1) { - milliaIndex = 0; - millias[milliaIndex] += (centillions * 100); - groupNameCurrent = groupNameCurrent.slice(CENTILLIONS_PREFIXES[centillions].length); - continue; + if (result.groupNameCurrent.startsWith(`${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}`)) { + // short millia + const matchedMilliaArray = result.groupNameCurrent.match(/^\d+/); + if (!matchedMilliaArray) { + throw new InvalidTokenError(result.groupNameCurrent); } + const matchedMillia = matchedMilliaArray[0]; + const newMillia = Number(matchedMillia); + const oldMillia = result.milliaIndex; + const newMillias = [...result.millias]; + newMillias[newMillia] = newMillias[oldMillia] || 1; + newMillias[oldMillia] = 0; + return { + milliaIndex: newMillia, + millias: newMillias, + groupNameCurrent: result.groupNameCurrent.slice(MILLIA_PREFIX.length), + done: false, + }; + } - const decillions = DECILLIONS_PREFIXES.findIndex((p) => ( - p.length > 0 && groupNameCurrent.startsWith(p) - )); + if (result.groupNameCurrent.startsWith(MILLIA_PREFIX)) { + const newMillia = result.milliaIndex + 1; + const oldMillia = result.milliaIndex; + const newMillias = [...result.millias]; + newMillias[newMillia] = newMillias[oldMillia] || 1; + newMillias[oldMillia] = 0; + return { + milliaIndex: newMillia, + millias: newMillias, + groupNameCurrent: result.groupNameCurrent.slice(MILLIA_PREFIX.length), + done: false, + }; + } - if (decillions > -1) { - milliaIndex = 0; - millias[milliaIndex] += decillions * 10; - groupNameCurrent = groupNameCurrent.slice(DECILLIONS_PREFIXES[decillions].length); - continue; - } + throw new InvalidTokenError(result.groupNameCurrent); +}; - const millions = MILLIONS_PREFIXES.findIndex((p) => ( - p.length > 0 && groupNameCurrent.startsWith(p) - )); +const getGroupPlaceFromGroupName = (groupName: string) => { + if (groupName === THOUSAND) { + return 1; + } - if (millions > -1) { - milliaIndex = 0; - millias[milliaIndex] += millions; - groupNameCurrent = groupNameCurrent.slice(MILLIONS_PREFIXES[millions].length); - continue; - } + const groupNameBase = groupName.replace(ILLION_SUFFIX, ''); + const specialMillions = MILLIONS_SPECIAL_PREFIXES.findIndex((p) => groupNameBase === p); - if (groupNameCurrent.startsWith(`${MILLIA_PREFIX}^`)) { - // short millia - groupNameCurrent = groupNameCurrent.slice(MILLIA_PREFIX.length); - const matchedMilliaArray = groupNameCurrent.match(/^\d+/); - if (!matchedMilliaArray) { - throw new Error(`Invalid groupName: ${groupName}`); - } - const matchedMillia = matchedMilliaArray[0]; - millias[Number(matchedMillia)] = millias[milliaIndex] || 1; - millias[milliaIndex] = 0; - groupNameCurrent = groupNameCurrent.slice(matchedMillia.length); - } + if (specialMillions > -1) { + return specialMillions + 1; + } - if (groupNameCurrent.startsWith(MILLIA_PREFIX)) { - millias[milliaIndex + 1] = millias[milliaIndex] || 1; - millias[milliaIndex] = 0; - milliaIndex += 1; - groupNameCurrent = groupNameCurrent.slice(MILLIA_PREFIX.length); - continue; - } + let result: DoParseState = { + groupNameCurrent: groupNameBase, + millias: [0], + milliaIndex: 0, + done: false, + }; - break; - } + do { + result = doParseGroupName(result); + } while (!result.done); const bigGroupPlace = Number( - millias + result.millias .map((s) => s.toString().padStart(3, '0')) .reverse() .join(''), ); - return 1 + bigGroupPlace; + return bigGroupPlace + 1; }; enum ParseGroupsMode { @@ -440,7 +577,7 @@ interface ParserState { export const parseGroups = (tokens: string[]) => { const { groups } = [...tokens, FINAL_TOKEN].reduce( (acc, token) => { - const lastGroup = acc.groups.at(-1) ?? ['000', 0]; + const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE]; if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) { if (acc.mode === ParseGroupsMode.ONES_MODE) { @@ -448,7 +585,7 @@ export const parseGroups = (tokens: string[]) => { lastGroup[0] = `${lastGroup[0].slice(0, 2)}${ones}`; } - lastGroup[1] = getGroupFromGroupName(token); + lastGroup[1] = getGroupPlaceFromGroupName(token); return { ...acc, @@ -489,7 +626,7 @@ export const parseGroups = (tokens: string[]) => { ...acc, lastToken: token, mode: ParseGroupsMode.ONES_MODE, - groups: [...acc.groups, ['000', 0]], + groups: [...acc.groups, [...EMPTY_PLACE]], }; } return { @@ -551,11 +688,14 @@ export const combineGroups = (groups: Group[]) => { const firstGroupDigitsWithoutZeroes = firstGroupDigits.replace(/^0+/, ''); const exponentExtra = firstGroupDigits.length - firstGroupDigitsWithoutZeroes.length; const exponentValue = BigInt((firstGroupPlace * 3) + (2 - exponentExtra)); - const exponent = exponentValue < 0 ? exponentValue.toString() : `+${exponentValue}`; + const isExponentNegative = exponentValue < 0; + const exponentValueAbs = isExponentNegative ? -exponentValue : exponentValue; + const exponentSign = isExponentNegative ? NEGATIVE_SYMBOL : POSITIVE_SYMBOL; + const exponent = `${exponentSign}${exponentValueAbs}`; const significandInteger = digits.slice(0, 1); const significandFraction = digits.slice(1); if (significandFraction.length > 0) { - return `${significandInteger}${DECIMAL_POINT}${significandFraction}e${exponent}`; + return `${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`; } - return `${significandInteger}e${exponent}`; + return `${significandInteger}${EXPONENT_DELIMITER}${exponent}`; }; diff --git a/packages/core/test/systems/en-US.test.ts b/packages/core/test/systems/en-US.test.ts index 1e33d59..3cffb07 100644 --- a/packages/core/test/systems/en-US.test.ts +++ b/packages/core/test/systems/en-US.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import {parse, stringify, systems} from '../../src'; +import { numberToExponential } from '../../src/exponent'; const options = { system: systems.enUS }; @@ -242,4 +243,9 @@ describe('numerica', () => { expect(stringify(value, options)).toBe(expected); expect(parse(expected, options)).toBe(value); }); + + it('converts values', () => { + const exp = numberToExponential('123456789012345678901234567890'); + expect(parse(stringify('123456789012345678901234567890'))).toBe(exp); + }); });