import { bigIntMax, bigIntMin, Group, GROUP_DIGITS_INDEX, GROUP_PLACE_INDEX, GroupDigits, InvalidTokenError, } from '../../../common'; import { CENTILLIONS_PREFIXES, DECILLIONS_PREFIXES, DECIMAL_POINT, EMPTY_GROUP_DIGITS, EMPTY_PLACE, EXPONENT_DELIMITER, HUNDRED, ILLION_SUFFIX, MILLIA_PREFIX, MILLIONS_PREFIXES, MILLIONS_SPECIAL_PREFIXES, NEGATIVE, NEGATIVE_SYMBOL, ONES, OnesName, POSITIVE_SYMBOL, SHORT_MILLIA_DELIMITER, SHORT_MILLIA_ILLION_DELIMITER, T_AFFIX, TEN_PLUS_ONES, TenPlusOnesName, TENS, TENS_ONES_SEPARATOR, TensName, THOUSAND, } from '../../en/common'; const FINAL_TOKEN = '' as const; /** * Tokenizes a string. * @param value - The string to tokenize. * @see {NumberNameSystem.mergeTokens} * @returns string[] The tokens. */ export const tokenize = (value: string) => ( value .toLowerCase() .trim() .replace(/\n+/gs, ' ') .replace(/\s+/g, ' ') .replace( new RegExp(`${THOUSAND}\\s+(.+?${ILLION_SUFFIX})`, 'g'), (_substring, illion: string) => ( `${THOUSAND}${illion}` ), ) .replace( new RegExp(`${MILLIA_PREFIX}\\${SHORT_MILLIA_DELIMITER}(\\d+)${SHORT_MILLIA_ILLION_DELIMITER}`, 'g'), (_substring, milliaCount: string) => `${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}${milliaCount}`, ) .replace(new RegExp(`${TENS_ONES_SEPARATOR}`, 'g'), ' ') .split(' ') .filter((maybeToken) => maybeToken.length > 0) ); interface DoParseState { groupNameCurrent: string; millias: number[]; milliaIndex: number; done: boolean; } /** * Deconstructs a group name token (e.g. "million", "duodecillion", etc.) to its affixes and * parses them. * @param result - The current state of the parser. * @returns DoParseState The next state of the parser. */ const doParseGroupName = (result: DoParseState): DoParseState => { if ( result.groupNameCurrent.length < 1 // 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. || result.groupNameCurrent === T_AFFIX ) { return { ...result, // Fill the gaps of millias with zeros. millias: new Array(result.millias.length) .fill(0) .map((z, i) => ( result.millias[i] ?? z )), done: true, }; } 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, }; } 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 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 (result.groupNameCurrent.startsWith(MILLIA_PREFIX)) { let newMillia: number; let prefix: string; const isShortMillia = result.groupNameCurrent.startsWith(`${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}`); if (isShortMillia) { const matchedMilliaArray = result.groupNameCurrent .match(new RegExp(`^${MILLIA_PREFIX}\\${SHORT_MILLIA_DELIMITER}(\\d+)`)); if (!matchedMilliaArray) { throw new InvalidTokenError(result.groupNameCurrent); } const [wholeMilliaPrefix, matchedMillia] = matchedMilliaArray; newMillia = Number(matchedMillia); prefix = wholeMilliaPrefix; } else { newMillia = result.milliaIndex + 1; prefix = MILLIA_PREFIX; } 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(prefix.length), done: false, }; } throw new InvalidTokenError(result.groupNameCurrent); }; /** * Gets the place of a group name (e.g. "million", "duodecillion", etc.). * @param groupName - The group name. * @returns bigint The place of the group name. */ const getGroupPlaceFromGroupName = (groupName: string) => { if (groupName === THOUSAND) { return BigInt(1); } const groupNameBase = groupName.replace(ILLION_SUFFIX, '').replace(THOUSAND, ''); const specialMillions = MILLIONS_SPECIAL_PREFIXES.findIndex((p) => groupNameBase === p); if (specialMillions > -1) { return BigInt(specialMillions * 2) + (groupName.startsWith(THOUSAND) ? BigInt(1) : BigInt(0)); } let result: DoParseState = { groupNameCurrent: groupNameBase, millias: [0], milliaIndex: 0, done: false, }; do { result = doParseGroupName(result); } while (!result.done); const bigGroupPlace = BigInt( result.millias .map((s) => s.toString().padStart(3, '0')) .reverse() .join(''), ); return bigGroupPlace * BigInt(2) + (groupName.startsWith(THOUSAND) ? BigInt(1) : BigInt(0)); }; /** * Mode of the group parser. */ enum ParseGroupsMode { /** * Initial mode. */ INITIAL = 'initial', /** * Has parsed a ones name. */ ONES_MODE = 'ones', /** * Has parsed a tens name. */ TENS_MODE = 'tens', /** * Has parsed a ten-plus-ones name. */ TEN_PLUS_ONES_MODE = 'tenPlusOnes', /** * Has parsed a "hundred" token. */ HUNDRED_MODE = 'hundred', /** * Has parsed a "thousand" or any "-illion" token. */ THOUSAND_MODE = 'thousand', /** * Done parsing. */ DONE = 'done', } /** * State of the group parser. */ interface ParserState { lastToken?: string; groups: Group[]; mode: ParseGroupsMode; negative: boolean; } const parseThousand = (acc: ParserState, token: string): ParserState => { const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE]; if (acc.mode === ParseGroupsMode.ONES_MODE) { const ones = ONES.findIndex((o) => o === acc.lastToken); if (ones > -1) { lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 2)}${ones}` as GroupDigits; } } else if (acc.mode === ParseGroupsMode.TENS_MODE) { const tens = TENS.findIndex((t) => t === acc.lastToken); if (tens > -1) { lastGroup[GROUP_DIGITS_INDEX] = ( `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}` ) as GroupDigits; } } // Put the digits in the right place. lastGroup[GROUP_PLACE_INDEX] = getGroupPlaceFromGroupName(token); return { ...acc, groups: [...acc.groups.slice(0, -1), lastGroup], lastToken: token, mode: ParseGroupsMode.THOUSAND_MODE, }; }; const parseHundred = (acc: ParserState): ParserState => { const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE]; const hundreds = ONES.findIndex((o) => o === acc.lastToken); lastGroup[GROUP_DIGITS_INDEX] = `${hundreds}${lastGroup[GROUP_DIGITS_INDEX].slice(1)}` as GroupDigits; return { ...acc, groups: [...acc.groups.slice(0, -1), lastGroup], mode: ParseGroupsMode.HUNDRED_MODE, }; }; const parseFinal = (acc: ParserState): ParserState => { const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE]; if (acc.mode === ParseGroupsMode.ONES_MODE) { const ones = ONES.findIndex((o) => o === acc.lastToken); if (ones > -1) { lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 2)}${ones}` as GroupDigits; } // We assume last token without parsed place will always be the smallest lastGroup[GROUP_PLACE_INDEX] = BigInt(0); return { ...acc, groups: [...acc.groups.slice(0, -1), lastGroup], mode: ParseGroupsMode.DONE, }; } if (acc.mode === ParseGroupsMode.TENS_MODE) { const tens = TENS.findIndex((o) => o === acc.lastToken); if (tens > -1) { lastGroup[GROUP_DIGITS_INDEX] = ( `${lastGroup[0].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}` ) as GroupDigits; } lastGroup[GROUP_PLACE_INDEX] = BigInt(0); return { ...acc, groups: [...acc.groups.slice(0, -1), lastGroup], mode: ParseGroupsMode.DONE, }; } return acc; }; const parseOnes = (acc: ParserState, token: string): ParserState => { if (acc.mode === ParseGroupsMode.THOUSAND_MODE) { // Create next empty place return { ...acc, lastToken: token, mode: ParseGroupsMode.ONES_MODE, groups: [...acc.groups, [...EMPTY_PLACE]], }; } return { ...acc, lastToken: token, mode: ParseGroupsMode.ONES_MODE, }; }; const parseTenPlusOnes = (acc: ParserState, token: string): ParserState => { const tenPlusOnes = TEN_PLUS_ONES.findIndex((t) => t === token); const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE]; if (acc.mode === ParseGroupsMode.THOUSAND_MODE) { return { ...acc, lastToken: token, mode: ParseGroupsMode.TEN_PLUS_ONES_MODE, groups: [...acc.groups, [`01${tenPlusOnes}` as GroupDigits, lastGroup[GROUP_PLACE_INDEX] - BigInt(1)]], }; } lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}1${tenPlusOnes}` as GroupDigits; return { ...acc, lastToken: token, mode: ParseGroupsMode.TEN_PLUS_ONES_MODE, groups: [...acc.groups.slice(0, -1), lastGroup], }; }; const parseTens = (acc: ParserState, token: string): ParserState => { const tens = TENS.findIndex((t) => t === token); const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE]; if (acc.mode === ParseGroupsMode.THOUSAND_MODE) { return { ...acc, lastToken: token, mode: ParseGroupsMode.TENS_MODE, groups: [...acc.groups, [`0${tens}0` as GroupDigits, lastGroup[GROUP_PLACE_INDEX] - BigInt(1)]], }; } lastGroup[GROUP_DIGITS_INDEX] = ( `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}` ) as GroupDigits; return { ...acc, lastToken: token, mode: ParseGroupsMode.TENS_MODE, groups: [...acc.groups.slice(0, -1), lastGroup], }; }; /** * Parses groups from a string. * @param tokens - The string to parse groups from. * @see {NumberNameSystem.stringifyGroups} * @returns Group[] The parsed groups. */ export const parseGroups = (tokens: string[]) => { // We add a final token which is an empty string to parse whatever the last non-empty token is. const tokensToParse = [...tokens, FINAL_TOKEN]; const { groups, negative } = tokensToParse.reduce( (acc, token) => { if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) { return parseThousand(acc, token); } if (token === HUNDRED && acc.mode === ParseGroupsMode.ONES_MODE) { return parseHundred(acc); } if (token === FINAL_TOKEN) { return parseFinal(acc); } if (ONES.includes(token as OnesName)) { return parseOnes(acc, token); } if (TEN_PLUS_ONES.includes(token as TenPlusOnesName)) { return parseTenPlusOnes(acc, token); } if (TENS.includes(token as TensName)) { return parseTens(acc, token); } if (token === NEGATIVE) { return { ...acc, negative: !acc.negative, }; } return { ...acc, lastToken: token, }; }, { lastToken: undefined, groups: [], mode: ParseGroupsMode.INITIAL, negative: false, }, ); return { groups, negative }; }; /** * Combines groups into a string. * @param groups - The groups to combine. * @param negative - Whether the number is negative. * @see {NumberNameSystem.splitIntoGroups} * @returns string The combined groups in exponential form. */ export const combineGroups = (groups: Group[], negative: boolean) => { if (groups.length < 1) { return ''; } const places = groups.map((g) => g[GROUP_PLACE_INDEX]); const maxPlace = bigIntMax(...places) as bigint; const minPlace = bigIntMin(...places) as bigint; const firstGroup = groups.find((g) => g[GROUP_PLACE_INDEX] === maxPlace) ?? [...EMPTY_PLACE]; const firstGroupPlace = firstGroup[GROUP_PLACE_INDEX]; const groupsSorted = []; for (let i = maxPlace; i >= minPlace; i = BigInt(i) - BigInt(1)) { const thisGroup = groups.find((g) => g[GROUP_PLACE_INDEX] === i) ?? [EMPTY_GROUP_DIGITS, i]; groupsSorted.push(thisGroup); } const digits = groupsSorted.reduce( (previousDigits, thisGroup) => { const [groupDigits] = thisGroup; return `${previousDigits}${groupDigits}`; }, '', ).replace(/^0+/, '') || '0'; const firstGroupDigits = firstGroup[0]; const firstGroupDigitsWithoutZeroes = firstGroupDigits.replace(/^0+/, ''); const exponentExtra = firstGroupDigits.length - firstGroupDigitsWithoutZeroes.length; const exponentValue = BigInt( (BigInt(firstGroupPlace) * BigInt(3)) + (BigInt(2) - BigInt(exponentExtra)), ); 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).replace(/0+$/, ''); if (significandFraction.length > 0) { return `${negative ? NEGATIVE_SYMBOL : ''}${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`; } return `${negative ? NEGATIVE_SYMBOL : ''}${significandInteger}${EXPONENT_DELIMITER}${exponent}`; };