diff --git a/packages/core/src/common.ts b/packages/core/src/common.ts index 99cd092..a06882f 100644 --- a/packages/core/src/common.ts +++ b/packages/core/src/common.ts @@ -35,6 +35,16 @@ type GroupPlace = number; */ export type Group = [GroupDigits, GroupPlace]; +/** + * Index of the group digits in a {@link Group|group}. + */ +export const GROUP_DIGITS_INDEX = 0 as const; + +/** + * Index of the group place in a {@link Group|group}. + */ +export const GROUP_PLACE_INDEX = 1 as const; + /** * System for stringifying and parsing numbers. */ diff --git a/packages/core/src/systems/en-US.ts b/packages/core/src/systems/en-US.ts index babb9f2..cb4cf0c 100644 --- a/packages/core/src/systems/en-US.ts +++ b/packages/core/src/systems/en-US.ts @@ -1,6 +1,11 @@ // noinspection SpellCheckingInspection -import { Group, InvalidTokenError } from '../common'; +import { + Group, + GROUP_DIGITS_INDEX, + GROUP_PLACE_INDEX, + InvalidTokenError, +} from '../common'; import { numberToExponential } from '../exponent'; const DECIMAL_POINT = '.' as const; @@ -287,16 +292,20 @@ const getGroupName = (place: number, shortenMillia: boolean) => { (acc, c, i, cc) => { const firstGroup = acc.at(0); const currentPlace = Math.floor((cc.length - i - 1) / 3); + const newGroup = [EMPTY_GROUP_DIGITS, currentPlace] as Group; if (typeof firstGroup === 'undefined') { - return [[c, currentPlace]]; + newGroup[GROUP_DIGITS_INDEX] = c; + return [newGroup]; } if (firstGroup[0].length > 2) { - return [[c, currentPlace], ...acc]; + newGroup[GROUP_DIGITS_INDEX] = c; + return [newGroup, ...acc]; } + newGroup[GROUP_DIGITS_INDEX] = c + firstGroup[0]; return [ - [c + firstGroup[0], currentPlace], + newGroup, ...acc.slice(1), ]; }, @@ -378,7 +387,7 @@ export const group = (value: string): Group[] => { const currentPlace = Math.floor((exponent - i) / 3); const lastGroup = acc.at(-1) ?? [EMPTY_GROUP_DIGITS, currentPlace]; const currentPlaceInGroup = 2 - ((exponent - i) % 3); - if (lastGroup[1] === currentPlace) { + if (lastGroup[GROUP_PLACE_INDEX] === currentPlace) { const lastGroupDigits = lastGroup[0].split(''); lastGroupDigits[currentPlaceInGroup] = c; return [...acc.slice(0, -1) ?? [], [ @@ -584,99 +593,166 @@ interface ParserState { mode: ParseGroupsMode; } -export const parseGroups = (tokens: string[]) => { - const { groups } = [...tokens, FINAL_TOKEN].reduce( - (acc, token) => { - const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE]; +const parseThousand = (acc: ParserState, token: string): ParserState => { + const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE]; - if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) { - if (acc.mode === ParseGroupsMode.ONES_MODE) { - const ones = ONES.findIndex((o) => o === acc.lastToken); - if (ones > -1) { - lastGroup[0] = `${lastGroup[0].slice(0, 2)}${ones}`; - } - } + 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}`; + } + } 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)}` + ); + } + } + + // 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)}`; + 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}`; + } + // We assume last token without parsed place will always be the smallest + lastGroup[GROUP_PLACE_INDEX] = 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)}`; + } + lastGroup[GROUP_PLACE_INDEX] = 0; + return { + ...acc, + groups: [...acc.groups.slice(0, -1), lastGroup], + mode: ParseGroupsMode.DONE, + }; + } + + return acc; +}; - lastGroup[1] = getGroupPlaceFromGroupName(token); +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}`, lastGroup[GROUP_PLACE_INDEX] - 1]], + }; + } + + lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}1${tenPlusOnes}`; + 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`, lastGroup[GROUP_PLACE_INDEX] - 1]], + }; + } - return { - ...acc, - groups: [...acc.groups.slice(0, -1), lastGroup], - lastToken: token, - mode: ParseGroupsMode.THOUSAND_MODE, - }; + lastGroup[GROUP_DIGITS_INDEX] = ( + `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}` + ); + return { + ...acc, + lastToken: token, + mode: ParseGroupsMode.TENS_MODE, + groups: [...acc.groups.slice(0, -1), lastGroup], + }; +}; + +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 } = tokensToParse.reduce( + (acc, token) => { + if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) { + return parseThousand(acc, token); } - if (token === HUNDRED) { - if (acc.mode === ParseGroupsMode.ONES_MODE) { - const hundreds = ONES.findIndex((o) => o === acc.lastToken); - lastGroup[0] = `${hundreds}${lastGroup[0].slice(1)}`; - return { - ...acc, - groups: [...acc.groups.slice(0, -1), lastGroup], - mode: ParseGroupsMode.HUNDRED_MODE, - }; - } + if (token === HUNDRED && acc.mode === ParseGroupsMode.ONES_MODE) { + return parseHundred(acc); } if (token === FINAL_TOKEN) { - if (acc.mode === ParseGroupsMode.ONES_MODE) { - const ones = ONES.findIndex((o) => o === acc.lastToken); - lastGroup[0] = `${lastGroup[0].slice(0, 2)}${ones}`; - lastGroup[1] = 0; - return { - ...acc, - groups: [...acc.groups.slice(0, -1), lastGroup], - mode: ParseGroupsMode.DONE, - }; - } + return parseFinal(acc); } if (ONES.includes(token as OnesName)) { - if (acc.mode === ParseGroupsMode.THOUSAND_MODE) { - return { - ...acc, - lastToken: token, - mode: ParseGroupsMode.ONES_MODE, - groups: [...acc.groups, [...EMPTY_PLACE]], - }; - } - return { - ...acc, - lastToken: token, - mode: ParseGroupsMode.ONES_MODE, - }; + return parseOnes(acc, token); } - const tenPlusOnes = TEN_PLUS_ONES.findIndex((t) => t === token); - if (tenPlusOnes > -1) { - if (acc.mode === ParseGroupsMode.THOUSAND_MODE) { - return { - ...acc, - lastToken: token, - mode: ParseGroupsMode.ONES_MODE, - groups: [...acc.groups, [`01${tenPlusOnes}`, lastGroup[1] - 1]], - }; - } - - lastGroup[0] = `${lastGroup[0].slice(0, 1)}1${tenPlusOnes}`; - return { - ...acc, - lastToken: token, - mode: ParseGroupsMode.TEN_PLUS_ONES_MODE, - groups: [...acc.groups.slice(0, -1), lastGroup], - }; + if (TEN_PLUS_ONES.includes(token as TenPlusOnesName)) { + return parseTenPlusOnes(acc, token); } - const tens = TENS.findIndex((t) => t === token); - if (tens > -1) { - lastGroup[0] = `${lastGroup[0].slice(0, 1)}${tens}${lastGroup[0].slice(2)}`; - return { - ...acc, - lastToken: token, - mode: ParseGroupsMode.TENS_MODE, - groups: [...acc.groups.slice(0, -1), lastGroup], - }; + if (TENS.includes(token as TensName)) { + return parseTens(acc, token); } return { @@ -695,14 +771,14 @@ export const parseGroups = (tokens: string[]) => { }; export const combineGroups = (groups: Group[]) => { - const places = groups.map((g) => g[1]); + const places = groups.map((g) => g[GROUP_PLACE_INDEX]); const maxPlace = Math.max(...places); const minPlace = Math.min(...places); - const firstGroup = groups.find((g) => g[1] === maxPlace) ?? [...EMPTY_PLACE]; - const firstGroupPlace = firstGroup[1]; + 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 -= 1) { - const thisGroup = groups.find((g) => g[1] === i) ?? [...EMPTY_PLACE]; + const thisGroup = groups.find((g) => g[GROUP_PLACE_INDEX] === i) ?? [...EMPTY_PLACE]; groupsSorted.push(thisGroup); } diff --git a/packages/core/test/systems/en-US.test.ts b/packages/core/test/systems/en-US.test.ts index 7aa8313..e01d6ab 100644 --- a/packages/core/test/systems/en-US.test.ts +++ b/packages/core/test/systems/en-US.test.ts @@ -263,5 +263,15 @@ describe('numerica', () => { const exp2 = numberToExponential(value2); expect(stringify(value2)).toBe('one trillion five million'); expect(parse(stringify(value2))).toBe(exp2); + + const value3 = '12012'; + const exp3 = numberToExponential(value3); + expect(stringify(value3)).toBe('twelve thousand twelve'); + expect(parse(stringify(value3))).toBe(exp3); + + const value4 = '12020'; + const exp4 = numberToExponential(value4); + expect(stringify(value4)).toBe('twelve thousand twenty'); + expect(parse(stringify(value4))).toBe(exp4); }); });