@@ -48,44 +48,56 @@ export const GROUP_PLACE_INDEX = 1 as const; | |||
/** | |||
* System for stringifying and parsing numbers. | |||
*/ | |||
export interface StringifySystem { | |||
export interface NumberNameSystem { | |||
/** | |||
* Creates a negative string. | |||
* @param s - The string to make negative. | |||
* @returns string The negative string. | |||
*/ | |||
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. | |||
* Splits a string into groups. | |||
* @param value - The string to group. | |||
* @see {NumberNameSystem.combineGroups} | |||
* @returns Group[] The groups. | |||
*/ | |||
makeGroups: <T extends object>(groups: Group[], options?: T) => string[]; | |||
splitIntoGroups: (value: string) => Group[]; | |||
/** | |||
* Groups a string. | |||
* @param value - The string to group. | |||
* Creates a group string. | |||
* @param groups - The groups. | |||
* @param options - Options to use when creating the group. | |||
* @see {NumberNameSystem.parseGroups} | |||
* @returns string[] The groups represented into strings. | |||
*/ | |||
group: (value: string) => Group[]; | |||
stringifyGroups: <T extends object>(groups: Group[], options?: T) => string[]; | |||
/** | |||
* Finalizes a string. | |||
* Merges tokens from stringified groups to a string. | |||
* @param tokens - The tokens to finalize. | |||
* @see {NumberNameSystem.tokenize} | |||
* @returns string The merged tokens. | |||
*/ | |||
finalize: <T extends object>(tokens: string[], options?: T) => string; | |||
mergeTokens: <T extends object>(tokens: string[], options?: T) => string; | |||
/** | |||
* Tokenizes a string. | |||
* @param value - The string to tokenize. | |||
* @see {NumberNameSystem.mergeTokens} | |||
* @returns string[] The tokens. | |||
*/ | |||
tokenize: (value: string) => string[]; | |||
/** | |||
* Parses groups from a string. | |||
* @param value - The string to parse groups from. | |||
* @param tokens - The string to parse groups from. | |||
* @see {NumberNameSystem.stringifyGroups} | |||
* @returns Group[] The parsed groups. | |||
*/ | |||
parseGroups: (value: string[]) => Group[]; | |||
parseGroups: (tokens: string[]) => Group[]; | |||
/** | |||
* Combines groups into a string. | |||
* @param value - The groups to combine. | |||
* @param groups - The groups to combine. | |||
* @see {NumberNameSystem.splitIntoGroups} | |||
* @returns string The combined groups in exponential form. | |||
*/ | |||
combineGroups: (value: Group[]) => string; | |||
combineGroups: (groups: Group[]) => string; | |||
} | |||
/** | |||
@@ -1,5 +1,5 @@ | |||
import { enUS } from './systems'; | |||
import { StringifySystem } from './common'; | |||
import { NumberNameSystem } from './common'; | |||
import { exponentialToNumberString, extractExponentialComponents, numberToExponential } from './exponent'; | |||
/** | |||
@@ -35,20 +35,23 @@ type ParseResult = typeof ALLOWED_PARSE_RESULT_TYPES[number]; | |||
* Options to use when converting a value to a string. | |||
*/ | |||
export interface StringifyOptions< | |||
TMakeGroupOptions extends object = object, | |||
TFinalizeOptions extends object = object, | |||
TStringifyGroupsOptions extends object, | |||
TMergeTokensOptions extends object, | |||
> { | |||
/** | |||
* The system to use when converting a value to a string. | |||
* | |||
* Defaults to en-US (American short count). | |||
*/ | |||
system?: StringifySystem; | |||
system?: NumberNameSystem; | |||
/** | |||
* Options to use when making a group. This is used to override the default options for a group. | |||
* Options to use when stringifying a single group. | |||
*/ | |||
makeGroupOptions?: TMakeGroupOptions; | |||
finalizeOptions?: TFinalizeOptions; | |||
stringifyGroupsOptions?: TStringifyGroupsOptions; | |||
/** | |||
* Options to use when merging tokens. | |||
*/ | |||
mergeTokensOptions?: TMergeTokensOptions; | |||
} | |||
/** | |||
@@ -57,10 +60,14 @@ export interface StringifyOptions< | |||
* @param options - Options to use when converting a value to its name. | |||
* @returns string The name of the value. | |||
*/ | |||
export const stringify = ( | |||
value: AllowedValue, | |||
options = {} as StringifyOptions, | |||
): string => { | |||
export const stringify = < | |||
TStringifyGroupsOptions extends object, | |||
TMergeTokensOptions extends object | |||
> | |||
( | |||
value: AllowedValue, | |||
options = {} as StringifyOptions<TStringifyGroupsOptions, TMergeTokensOptions>, | |||
): string => { | |||
if (!( | |||
(ALLOWED_PARSE_RESULT_TYPES as unknown as string[]) | |||
.includes(typeof (value as unknown)) | |||
@@ -69,18 +76,15 @@ export const stringify = ( | |||
} | |||
const valueStr = value.toString().replace(/\s/g, ''); | |||
const { system = enUS.shortCount, makeGroupOptions, finalizeOptions } = options; | |||
const { system = enUS.shortCount, stringifyGroupsOptions, mergeTokensOptions } = options; | |||
if (valueStr.startsWith(NEGATIVE_SYMBOL)) { | |||
return system.makeNegative(stringify(valueStr.slice(NEGATIVE_SYMBOL.length), options)); | |||
} | |||
const groups = system.group(valueStr); | |||
const groupNames = system.makeGroups( | |||
groups, | |||
makeGroupOptions, | |||
); | |||
return system.finalize(groupNames, finalizeOptions); | |||
const groups = system.splitIntoGroups(valueStr); | |||
const groupNames = system.stringifyGroups(groups, stringifyGroupsOptions); | |||
return system.mergeTokens(groupNames, mergeTokensOptions); | |||
}; | |||
/** | |||
@@ -92,7 +96,7 @@ export interface ParseOptions { | |||
* | |||
* Defaults to en-US (American short count). | |||
*/ | |||
system?: StringifySystem; | |||
system?: NumberNameSystem; | |||
/** | |||
* The type to parse the value as. | |||
*/ | |||
@@ -16,12 +16,16 @@ export const POSITIVE_SYMBOL = '+' as const; | |||
export const SHORT_MILLIA_DELIMITER = '^' as const; | |||
export const SHORT_MILLIA_ILLION_DELIMITER = '-' as const; | |||
export const EXPONENT_DELIMITER = 'e' as const; | |||
export const EMPTY_GROUP_DIGITS = '000' as const; | |||
export const EMPTY_PLACE: Group = [EMPTY_GROUP_DIGITS, BigInt(0)]; | |||
export const T_AFFIX = 't' as const; | |||
/** | |||
* Ones number names. | |||
*/ | |||
@@ -21,6 +21,8 @@ import { | |||
OnesName, | |||
POSITIVE_SYMBOL, | |||
SHORT_MILLIA_DELIMITER, | |||
SHORT_MILLIA_ILLION_DELIMITER, | |||
T_AFFIX, | |||
TEN_PLUS_ONES, | |||
TenPlusOnesName, | |||
TENS, | |||
@@ -31,12 +33,22 @@ import { | |||
const FINAL_TOKEN = '' as const; | |||
export const tokenize = (stringValue: string) => ( | |||
stringValue | |||
/** | |||
* 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(`${SHORT_MILLIA_ILLION_DELIMITER}${T_AFFIX}${ILLION_SUFFIX}`, 'g'), | |||
`${T_AFFIX}${ILLION_SUFFIX}`, | |||
) | |||
.replace(new RegExp(`${TENS_ONES_SEPARATOR}`, 'g'), ' ') | |||
.split(' ') | |||
.filter((maybeToken) => maybeToken.length > 0) | |||
@@ -49,19 +61,29 @@ interface DoParseState { | |||
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) { | |||
return { | |||
...result, | |||
done: true, | |||
}; | |||
} | |||
if ( | |||
result.groupNameCurrent.length < 1 | |||
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. | |||
|| 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, | |||
}; | |||
} | |||
@@ -120,29 +142,23 @@ 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); | |||
} | |||
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, | |||
}; | |||
} | |||
if (result.groupNameCurrent.startsWith(MILLIA_PREFIX)) { | |||
const newMillia = result.milliaIndex + 1; | |||
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; | |||
@@ -150,7 +166,7 @@ const doParseGroupName = (result: DoParseState): DoParseState => { | |||
return { | |||
milliaIndex: newMillia, | |||
millias: newMillias, | |||
groupNameCurrent: result.groupNameCurrent.slice(MILLIA_PREFIX.length), | |||
groupNameCurrent: result.groupNameCurrent.slice(prefix.length), | |||
done: false, | |||
}; | |||
} | |||
@@ -158,6 +174,11 @@ const doParseGroupName = (result: DoParseState): DoParseState => { | |||
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); | |||
@@ -191,16 +212,43 @@ const getGroupPlaceFromGroupName = (groupName: string) => { | |||
return bigGroupPlace + BigInt(1); | |||
}; | |||
/** | |||
* Mode of the group parser. | |||
*/ | |||
enum ParseGroupsMode { | |||
INITIAL = 'unknown', | |||
/** | |||
* 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[]; | |||
@@ -340,6 +388,12 @@ const parseTens = (acc: ParserState, token: string): ParserState => { | |||
}; | |||
}; | |||
/** | |||
* 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]; | |||
@@ -404,6 +458,12 @@ const bigIntMin = (...b: bigint[]) => b.reduce( | |||
undefined as bigint | undefined, | |||
); | |||
/** | |||
* Combines groups into a string. | |||
* @param groups - The groups to combine. | |||
* @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) { | |||
@@ -24,7 +24,7 @@ import { | |||
NEGATIVE, | |||
ONES, | |||
OnesName, | |||
SHORT_MILLIA_DELIMITER, | |||
SHORT_MILLIA_DELIMITER, SHORT_MILLIA_ILLION_DELIMITER, T_AFFIX, | |||
TEN_PLUS_ONES, | |||
TenPlusOnesName, | |||
TENS, | |||
@@ -135,6 +135,12 @@ const makeCentillionsPrefix = ( | |||
return `${hundredsName}${onesPrefix}${decillions > 0 ? tensName : ''}` as const; | |||
}; | |||
/** | |||
* Repeats a string a given number of times. | |||
* @param s - String to repeat. | |||
* @param count - Number of times to repeat the string. | |||
* @returns string The repeated string. | |||
*/ | |||
const repeatString = (s: string, count: GroupPlace) => { | |||
let result = ''; | |||
for (let i = BigInt(0); i < count; i += BigInt(1)) { | |||
@@ -183,13 +189,20 @@ const getGroupName = (place: GroupPlace, shortenMillia: boolean) => { | |||
.filter(([groupDigits]) => groupDigits !== EMPTY_GROUP_DIGITS) | |||
.map(([groupDigits, groupPlace], _index, millias) => { | |||
const [hundreds, tens, ones] = groupDigits.split('').map(Number); | |||
const centillionsPrefix = makeCentillionsPrefix( | |||
hundreds, | |||
tens, | |||
ones, | |||
BigInt(millias.length - 1) | |||
); | |||
if (groupPlace < 1) { | |||
return makeCentillionsPrefix(hundreds, tens, ones, BigInt(millias.length - 1)); | |||
return centillionsPrefix; | |||
} | |||
const milliaSuffix = ( | |||
shortenMillia && groupPlace > 1 | |||
? `${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}${groupPlace}` | |||
? `${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}${groupPlace}${SHORT_MILLIA_ILLION_DELIMITER}` | |||
: repeatString(MILLIA_PREFIX, groupPlace) | |||
); | |||
@@ -197,7 +210,7 @@ const getGroupName = (place: GroupPlace, shortenMillia: boolean) => { | |||
return milliaSuffix; | |||
} | |||
return makeCentillionsPrefix(hundreds, tens, ones, BigInt(millias.length - 1)) + milliaSuffix; | |||
return `${centillionsPrefix}${milliaSuffix}`; | |||
}) | |||
.join(''); | |||
@@ -206,18 +219,31 @@ const getGroupName = (place: GroupPlace, shortenMillia: boolean) => { | |||
} | |||
if (bigGroupPlace > 10) { | |||
return `${groupGroups}t${ILLION_SUFFIX}` as const; | |||
// vigin - t - illion, cen - t - illion, etc. | |||
return `${groupGroups}${T_AFFIX}${ILLION_SUFFIX}` as const; | |||
} | |||
return `${groupGroups}${ILLION_SUFFIX}` as const; | |||
}; | |||
export interface MakeGroupsOptions { | |||
export interface StringifyGroupsOptions { | |||
/** | |||
* Whether to add dashes between tens and ones (e.g. "sixty-nine"). | |||
*/ | |||
addTensDashes?: boolean; | |||
/** | |||
* Use "millia^2-tillion" instead of "milliamilliatillion". | |||
*/ | |||
shortenMillia?: boolean; | |||
} | |||
export const makeGroups = (groups: Group[], options?: MakeGroupsOptions): string[] => { | |||
/** | |||
* Creates a group string. | |||
* @param groups - The groups. | |||
* @param options - Options to use when creating the group. | |||
* @returns string[] The groups represented into strings. | |||
*/ | |||
export const stringifyGroups = (groups: Group[], options?: StringifyGroupsOptions): string[] => { | |||
const filteredGroups = groups.filter(([digits, place]) => ( | |||
place === BigInt(0) || digits !== EMPTY_GROUP_DIGITS | |||
)); | |||
@@ -245,8 +271,9 @@ export const makeGroups = (groups: Group[], options?: MakeGroupsOptions): string | |||
/** | |||
* Group a number string into groups of three digits, starting from the decimal point. | |||
* @param value - The number string to group. | |||
* @returns Group[] The groups. | |||
*/ | |||
export const group = (value: string): Group[] => { | |||
export const splitIntoGroups = (value: string): Group[] => { | |||
const [significand, exponentString] = numberToExponential( | |||
value, | |||
{ | |||
@@ -277,7 +304,7 @@ export const group = (value: string): Group[] => { | |||
); | |||
}; | |||
export interface FinalizeOptions { | |||
export interface MergeTokensOptions { | |||
oneGroupPerLine?: boolean; | |||
} | |||
@@ -286,7 +313,7 @@ export interface FinalizeOptions { | |||
* @param tokens - The tokens to finalize. | |||
* @param options - The options to use. | |||
*/ | |||
export const finalize = (tokens: string[], options?: FinalizeOptions) => ( | |||
export const mergeTokens = (tokens: string[], options?: MergeTokensOptions) => ( | |||
tokens | |||
.map((t) => t.trim()) | |||
.join(options?.oneGroupPerLine ? '\n' : GROUP_SEPARATOR) | |||
@@ -8,7 +8,7 @@ const options = { | |||
const stringifyOptions = { | |||
...options, | |||
makeGroupOptions: { | |||
stringifyGroupsOptions: { | |||
addTensDashes: false, | |||
}, | |||
}; | |||
@@ -286,4 +286,14 @@ describe('numerica', () => { | |||
const value5 = '1e3006'; | |||
expect(stringify(value5)).toBe('one milliauntillion'); | |||
}); | |||
it('converts short millia', () => { | |||
const shortMillia1 = 'one millia^1-tillion'; | |||
expect(parse(shortMillia1)).toBe('1e+3003'); | |||
const shortMillia2 = 'one millia^2-tillion'; | |||
expect(parse(shortMillia2)).toBe('1e+3000003'); | |||
expect(stringify(parse(shortMillia2), { stringifyGroupsOptions: { shortenMillia: true } })) | |||
.toBe(shortMillia2); | |||
}); | |||
}); |
@@ -3,7 +3,7 @@ import { stringify, parse } from '../../../src'; | |||
import { numberToExponential } from '../../../src/exponent'; | |||
const stringifyOptions = { | |||
makeGroupOptions: { | |||
stringifyGroupsOptions: { | |||
addTensDashes: false, | |||
}, | |||
}; | |||