@@ -45,6 +45,20 @@ export const GROUP_DIGITS_INDEX = 0 as const; | |||
*/ | |||
export const GROUP_PLACE_INDEX = 1 as const; | |||
/** | |||
* Result of parsing a number string. | |||
*/ | |||
export interface ParseResult { | |||
/** | |||
* The parsed groups. | |||
*/ | |||
groups: Group[]; | |||
/** | |||
* Whether the number is negative. | |||
*/ | |||
negative: boolean; | |||
} | |||
/** | |||
* System for stringifying and parsing numbers. | |||
*/ | |||
@@ -90,14 +104,15 @@ export interface NumberNameSystem { | |||
* @see {NumberNameSystem.stringifyGroups} | |||
* @returns Group[] The parsed groups. | |||
*/ | |||
parseGroups: (tokens: string[]) => Group[]; | |||
parseGroups: (tokens: string[]) => ParseResult; | |||
/** | |||
* 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. | |||
*/ | |||
combineGroups: (groups: Group[]) => string; | |||
combineGroups: (groups: Group[], negative: boolean) => string; | |||
} | |||
/** | |||
@@ -113,8 +113,8 @@ export const parse = (value: string, options = {} as ParseOptions) => { | |||
const { system = enUS.shortCount, type = 'string' } = options; | |||
const tokens = system.tokenize(value); | |||
const groups = system.parseGroups(tokens); | |||
const stringValue = system.combineGroups(groups); | |||
const { groups, negative } = system.parseGroups(tokens); | |||
const stringValue = system.combineGroups(groups, negative); | |||
switch (type) { | |||
case 'number': { | |||
@@ -16,7 +16,7 @@ import { | |||
ILLION_SUFFIX, | |||
MILLIA_PREFIX, | |||
MILLIONS_PREFIXES, | |||
MILLIONS_SPECIAL_PREFIXES, | |||
MILLIONS_SPECIAL_PREFIXES, NEGATIVE, | |||
NEGATIVE_SYMBOL, | |||
ONES, | |||
OnesName, | |||
@@ -260,6 +260,7 @@ interface ParserState { | |||
lastToken?: string; | |||
groups: Group[]; | |||
mode: ParseGroupsMode; | |||
negative: boolean; | |||
} | |||
const parseThousand = (acc: ParserState, token: string): ParserState => { | |||
@@ -404,7 +405,7 @@ const parseTens = (acc: ParserState, token: string): ParserState => { | |||
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<ParserState>( | |||
const { groups, negative } = tokensToParse.reduce<ParserState>( | |||
(acc, token) => { | |||
if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) { | |||
return parseThousand(acc, token); | |||
@@ -430,6 +431,13 @@ export const parseGroups = (tokens: string[]) => { | |||
return parseTens(acc, token); | |||
} | |||
if (token === NEGATIVE) { | |||
return { | |||
...acc, | |||
negative: !acc.negative, | |||
}; | |||
} | |||
return { | |||
...acc, | |||
lastToken: token, | |||
@@ -439,23 +447,25 @@ export const parseGroups = (tokens: string[]) => { | |||
lastToken: undefined, | |||
groups: [], | |||
mode: ParseGroupsMode.INITIAL, | |||
negative: false, | |||
}, | |||
); | |||
return groups; | |||
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[]) => { | |||
const places = groups.map((g) => g[GROUP_PLACE_INDEX]); | |||
if (places.length < 1) { | |||
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]; | |||
@@ -486,7 +496,7 @@ export const combineGroups = (groups: Group[]) => { | |||
const significandInteger = digits.slice(0, 1); | |||
const significandFraction = digits.slice(1).replace(/0+$/, ''); | |||
if (significandFraction.length > 0) { | |||
return `${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`; | |||
return `${negative ? NEGATIVE_SYMBOL : ''}${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`; | |||
} | |||
return `${significandInteger}${EXPONENT_DELIMITER}${exponent}`; | |||
return `${negative ? NEGATIVE_SYMBOL : ''}${significandInteger}${EXPONENT_DELIMITER}${exponent}`; | |||
}; |
@@ -16,7 +16,7 @@ import { | |||
ILLION_SUFFIX, | |||
MILLIA_PREFIX, | |||
MILLIONS_PREFIXES, | |||
MILLIONS_SPECIAL_PREFIXES, | |||
MILLIONS_SPECIAL_PREFIXES, NEGATIVE, | |||
NEGATIVE_SYMBOL, | |||
ONES, | |||
OnesName, | |||
@@ -47,7 +47,7 @@ export const tokenize = (value: string) => ( | |||
.replace(/\n+/gs, ' ') | |||
.replace(/\s+/g, ' ') | |||
.replace( | |||
new RegExp(`${MILLIA_PREFIX}\\${SHORT_MILLIA_DELIMITER}(\\d+)${SHORT_MILLIA_ILLION_DELIMITER}`, 'g'), | |||
new RegExp(`${MILLIA_PREFIX}\\${SHORT_MILLIA_DELIMITER}(.+?)${SHORT_MILLIA_ILLION_DELIMITER}`, 'g'), | |||
(_substring, milliaCount: string) => `${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}${milliaCount}`, | |||
) | |||
.replace(new RegExp(`${TENS_ONES_SEPARATOR}`, 'g'), ' ') | |||
@@ -66,9 +66,10 @@ interface DoParseState { | |||
* Deconstructs a group name token (e.g. "million", "duodecillion", etc.) to its affixes and | |||
* parses them. | |||
* @param result - The current state of the parser. | |||
* @param originalGroupName - The original group name token. | |||
* @returns DoParseState The next state of the parser. | |||
*/ | |||
const doParseGroupName = (result: DoParseState): DoParseState => { | |||
const doParseGroupName = (result: DoParseState, originalGroupName: string): DoParseState => { | |||
if ( | |||
result.groupNameCurrent.length < 1 | |||
@@ -151,10 +152,13 @@ const doParseGroupName = (result: DoParseState): DoParseState => { | |||
const matchedMilliaArray = result.groupNameCurrent | |||
.match(new RegExp(`^${MILLIA_PREFIX}\\${SHORT_MILLIA_DELIMITER}(\\d+)`)); | |||
if (!matchedMilliaArray) { | |||
throw new InvalidTokenError(result.groupNameCurrent); | |||
throw new InvalidTokenError(originalGroupName); | |||
} | |||
const [wholeMilliaPrefix, matchedMillia] = matchedMilliaArray; | |||
newMillia = Number(matchedMillia); | |||
if (newMillia < 1) { | |||
throw new InvalidTokenError(originalGroupName); | |||
} | |||
prefix = wholeMilliaPrefix; | |||
} else { | |||
newMillia = result.milliaIndex + 1; | |||
@@ -172,7 +176,7 @@ const doParseGroupName = (result: DoParseState): DoParseState => { | |||
}; | |||
} | |||
throw new InvalidTokenError(result.groupNameCurrent); | |||
throw new InvalidTokenError(originalGroupName); | |||
}; | |||
/** | |||
@@ -200,7 +204,7 @@ const getGroupPlaceFromGroupName = (groupName: string) => { | |||
}; | |||
do { | |||
result = doParseGroupName(result); | |||
result = doParseGroupName(result, groupName); | |||
} while (!result.done); | |||
const bigGroupPlace = BigInt( | |||
@@ -254,6 +258,7 @@ interface ParserState { | |||
lastToken?: string; | |||
groups: Group[]; | |||
mode: ParseGroupsMode; | |||
negative: boolean; | |||
} | |||
const parseThousand = (acc: ParserState, token: string): ParserState => { | |||
@@ -398,7 +403,7 @@ const parseTens = (acc: ParserState, token: string): ParserState => { | |||
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<ParserState>( | |||
const { groups, negative } = tokensToParse.reduce<ParserState>( | |||
(acc, token) => { | |||
if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) { | |||
return parseThousand(acc, token); | |||
@@ -424,35 +429,41 @@ export const parseGroups = (tokens: string[]) => { | |||
return parseTens(acc, token); | |||
} | |||
return { | |||
...acc, | |||
lastToken: token, | |||
}; | |||
if (token === NEGATIVE) { | |||
return { | |||
...acc, | |||
negative: !acc.negative, | |||
}; | |||
} | |||
throw new InvalidTokenError(token); | |||
}, | |||
{ | |||
lastToken: undefined, | |||
groups: [], | |||
mode: ParseGroupsMode.INITIAL, | |||
negative: false, | |||
}, | |||
); | |||
return groups; | |||
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[]) => { | |||
const places = groups.map((g) => g[GROUP_PLACE_INDEX]); | |||
if (places.length < 1) { | |||
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 firstGroup = groups.find((g) => g[GROUP_PLACE_INDEX] === maxPlace) as Group; | |||
const firstGroupPlace = firstGroup[GROUP_PLACE_INDEX]; | |||
const groupsSorted = []; | |||
for (let i = maxPlace; i >= minPlace; i = BigInt(i) - BigInt(1)) { | |||
@@ -480,7 +491,7 @@ export const combineGroups = (groups: Group[]) => { | |||
const significandInteger = digits.slice(0, 1); | |||
const significandFraction = digits.slice(1).replace(/0+$/, ''); | |||
if (significandFraction.length > 0) { | |||
return `${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`; | |||
return `${negative ? NEGATIVE_SYMBOL : ''}${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`; | |||
} | |||
return `${significandInteger}${EXPONENT_DELIMITER}${exponent}`; | |||
return `${negative ? NEGATIVE_SYMBOL : ''}${significandInteger}${EXPONENT_DELIMITER}${exponent}`; | |||
}; |
@@ -306,7 +306,7 @@ export const splitIntoGroups = (value: string): Group[] => { | |||
if (lastGroup[GROUP_PLACE_INDEX] === currentPlace) { | |||
const lastGroupDigits = lastGroup[0].split(''); | |||
lastGroupDigits[currentPlaceInGroup] = c; | |||
return [...acc.slice(0, -1) ?? [], [ | |||
return [...acc.slice(0, -1), [ | |||
lastGroupDigits.join(''), | |||
currentPlace, | |||
]]; | |||
@@ -29,6 +29,35 @@ const doExpect = ( | |||
}; | |||
describe('British long count', () => { | |||
it('returns empty string for empty input', () => { | |||
expect(parse('')).toBe(''); | |||
}); | |||
it('formats doubly negative numbers to be positive', () => { | |||
expect(stringify('--1')).toBe('one'); | |||
expect(stringify('----1')).toBe('one'); | |||
}); | |||
describe('error handling', () => { | |||
it('throws an error when given fully unparseable input', () => { | |||
expect(() => parse('foo')).toThrow(); | |||
}); | |||
it('throws an error when given partially unparseable input', () => { | |||
expect(() => parse('one milliamilliafootillion')).toThrow(); | |||
expect(() => parse('one millia^foo-tillion')).toThrow(); | |||
expect(() => parse('one millia^0-tillion')).toThrow(); | |||
}); | |||
}); | |||
describe('options', () => { | |||
it('formats one group per line', () => { | |||
expect(stringify(1234567, { mergeTokensOptions: { oneGroupPerLine: true } })).toBe(`one million | |||
two hundred thirty-four thousand | |||
five hundred sixty-seven`); | |||
}); | |||
}); | |||
describe('individual cases', () => { | |||
const longNumberValue1 = '123,456,789,012,345,678,901,234,567,890'; | |||
const longNumberStringified1 = [ | |||
@@ -29,6 +29,35 @@ const doExpect = ( | |||
}; | |||
describe('British short count', () => { | |||
it('returns empty string for empty input', () => { | |||
expect(parse('')).toBe(''); | |||
}); | |||
it('formats doubly negative numbers to be positive', () => { | |||
expect(stringify('--1')).toBe('one'); | |||
expect(stringify('----1')).toBe('one'); | |||
}); | |||
describe('error handling', () => { | |||
it('throws an error when given fully unparseable input', () => { | |||
expect(() => parse('foo')).toThrow(); | |||
}); | |||
it('throws an error when given partially unparseable input', () => { | |||
expect(() => parse('one milliamilliafootillion')).toThrow(); | |||
expect(() => parse('one millia^foo-tillion')).toThrow(); | |||
expect(() => parse('one millia^0-tillion')).toThrow(); | |||
}); | |||
}); | |||
describe('options', () => { | |||
it('formats one group per line', () => { | |||
expect(stringify(1234567, { mergeTokensOptions: { oneGroupPerLine: true } })).toBe(`one million | |||
two hundred thirty-four thousand | |||
five hundred sixty-seven`); | |||
}); | |||
}); | |||
describe('individual cases', () => { | |||
const longNumberValue1 = '123,456,789,012,345,678,901,234,567,890'; | |||
const longNumberStringified1 = [ | |||
@@ -48,10 +77,13 @@ describe('British short count', () => { | |||
it.each` | |||
value | stringified | parsedValue | |||
${-1000000} | ${'negative one million'} | ${-1000000} | |||
${-1000500} | ${'negative one million five hundred'} | ${-1000500} | |||
${1000} | ${'one thousand'} | ${1000} | |||
${10000} | ${'ten thousand'} | ${10000} | |||
${12012} | ${'twelve thousand twelve'} | ${12012} | |||
${12020} | ${'twelve thousand twenty'} | ${12020} | |||
${20000} | ${'twenty thousand'} | ${20000} | |||
${100000} | ${'one hundred thousand'} | ${100000} | |||
${123456} | ${'one hundred twenty-three thousand four hundred fifty-six'} | ${123456} | |||
${'1000005000000'} | ${'one trillion five million'} | ${'1.000005e+12'} | |||
@@ -29,6 +29,35 @@ const doExpect = ( | |||
}; | |||
describe('American short count', () => { | |||
it('returns empty string for empty input', () => { | |||
expect(parse('')).toBe(''); | |||
}); | |||
it('formats doubly negative numbers to be positive', () => { | |||
expect(stringify('--1')).toBe('one'); | |||
expect(stringify('----1')).toBe('one'); | |||
}); | |||
describe('error handling', () => { | |||
it('throws an error when given fully unparseable input', () => { | |||
expect(() => parse('foo')).toThrow(); | |||
}); | |||
it('throws an error when given partially unparseable input', () => { | |||
expect(() => parse('one milliamilliafootillion')).toThrow(); | |||
expect(() => parse('one millia^foo-tillion')).toThrow(); | |||
expect(() => parse('one millia^0-tillion')).toThrow(); | |||
}); | |||
}); | |||
describe('options', () => { | |||
it('formats one group per line', () => { | |||
expect(stringify(1234567, { mergeTokensOptions: { oneGroupPerLine: true } })).toBe(`one million | |||
two hundred thirty-four thousand | |||
five hundred sixty-seven`); | |||
}); | |||
}); | |||
describe('individual cases', () => { | |||
const longNumberValue1 = '123,456,789,012,345,678,901,234,567,890'; | |||
const longNumberStringified1 = [ | |||
@@ -48,6 +77,8 @@ describe('American short count', () => { | |||
it.each` | |||
value | stringified | parsedValue | |||
${-1000000} | ${'negative one million'} | ${-1000000} | |||
${-1000500} | ${'negative one million five hundred'} | ${-1000500} | |||
${1000} | ${'one thousand'} | ${1000} | |||
${10000} | ${'ten thousand'} | ${10000} | |||
${12012} | ${'twelve thousand twelve'} | ${12012} | |||