Gets the name of a number, even if it's stupidly big. Supersedes TheoryOfNekomata/number-name.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

487 lines
13 KiB

  1. import {
  2. bigIntMax, bigIntMin,
  3. Group,
  4. GROUP_DIGITS_INDEX,
  5. GROUP_PLACE_INDEX,
  6. InvalidTokenError,
  7. } from '../../../common';
  8. import {
  9. CENTILLIONS_PREFIXES,
  10. DECILLIONS_PREFIXES,
  11. DECIMAL_POINT,
  12. EMPTY_GROUP_DIGITS,
  13. EMPTY_PLACE,
  14. EXPONENT_DELIMITER,
  15. HUNDRED,
  16. ILLION_SUFFIX,
  17. MILLIA_PREFIX,
  18. MILLIONS_PREFIXES,
  19. MILLIONS_SPECIAL_PREFIXES,
  20. NEGATIVE_SYMBOL,
  21. ONES,
  22. OnesName,
  23. POSITIVE_SYMBOL,
  24. SHORT_MILLIA_DELIMITER,
  25. SHORT_MILLIA_ILLION_DELIMITER,
  26. T_AFFIX,
  27. TEN_PLUS_ONES,
  28. TenPlusOnesName,
  29. TENS,
  30. TENS_ONES_SEPARATOR,
  31. TensName,
  32. THOUSAND,
  33. } from '../../en/common';
  34. const FINAL_TOKEN = '' as const;
  35. /**
  36. * Tokenizes a string.
  37. * @param value - The string to tokenize.
  38. * @see {NumberNameSystem.mergeTokens}
  39. * @returns string[] The tokens.
  40. */
  41. export const tokenize = (value: string) => (
  42. value
  43. .toLowerCase()
  44. .trim()
  45. .replace(/\n+/gs, ' ')
  46. .replace(/\s+/g, ' ')
  47. .replace(
  48. new RegExp(`${MILLIA_PREFIX}\\${SHORT_MILLIA_DELIMITER}(\\d+)${SHORT_MILLIA_ILLION_DELIMITER}`, 'g'),
  49. (_substring, milliaCount: string) => `${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}${milliaCount}`,
  50. )
  51. .replace(new RegExp(`${TENS_ONES_SEPARATOR}`, 'g'), ' ')
  52. .split(' ')
  53. .filter((maybeToken) => maybeToken.length > 0)
  54. );
  55. interface DoParseState {
  56. groupNameCurrent: string;
  57. millias: number[];
  58. milliaIndex: number;
  59. done: boolean;
  60. }
  61. /**
  62. * Deconstructs a group name token (e.g. "million", "duodecillion", etc.) to its affixes and
  63. * parses them.
  64. * @param result - The current state of the parser.
  65. * @returns DoParseState The next state of the parser.
  66. */
  67. const doParseGroupName = (result: DoParseState): DoParseState => {
  68. if (
  69. result.groupNameCurrent.length < 1
  70. // If the current group name is "t", then we're done.
  71. // We use the -t- affix to attach the group prefix to the -illion suffix, except for decillion.
  72. || result.groupNameCurrent === T_AFFIX
  73. ) {
  74. return {
  75. ...result,
  76. // Fill the gaps of millias with zeros.
  77. millias: new Array(result.millias.length)
  78. .fill(0)
  79. .map((z, i) => (
  80. result.millias[i] ?? z
  81. )),
  82. done: true,
  83. };
  84. }
  85. const centillions = CENTILLIONS_PREFIXES.findIndex((p) => (
  86. p.length > 0 && result.groupNameCurrent.startsWith(p)
  87. ));
  88. if (centillions > -1) {
  89. return {
  90. milliaIndex: 0,
  91. millias: result.millias.map((m, i) => (
  92. i === 0
  93. ? m + (centillions * 100)
  94. : m
  95. )),
  96. groupNameCurrent: result.groupNameCurrent.slice(
  97. CENTILLIONS_PREFIXES[centillions].length,
  98. ),
  99. done: false,
  100. };
  101. }
  102. const decillions = DECILLIONS_PREFIXES.findIndex((p) => (
  103. p.length > 0 && result.groupNameCurrent.startsWith(p)
  104. ));
  105. if (decillions > -1) {
  106. return {
  107. milliaIndex: 0,
  108. millias: result.millias.map((m, i) => (
  109. i === 0
  110. ? m + (decillions * 10)
  111. : m
  112. )),
  113. groupNameCurrent: result.groupNameCurrent.slice(
  114. DECILLIONS_PREFIXES[decillions].length,
  115. ),
  116. done: false,
  117. };
  118. }
  119. const millions = MILLIONS_PREFIXES.findIndex((p) => (
  120. p.length > 0 && result.groupNameCurrent.startsWith(p)
  121. ));
  122. if (millions > -1) {
  123. return {
  124. milliaIndex: 0,
  125. millias: result.millias.map((m, i) => (
  126. i === 0
  127. ? m + millions
  128. : m
  129. )),
  130. groupNameCurrent: result.groupNameCurrent.slice(
  131. MILLIONS_PREFIXES[millions].length,
  132. ),
  133. done: false,
  134. };
  135. }
  136. if (result.groupNameCurrent.startsWith(MILLIA_PREFIX)) {
  137. let newMillia: number;
  138. let prefix: string;
  139. const isShortMillia = result.groupNameCurrent.startsWith(`${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}`);
  140. if (isShortMillia) {
  141. const matchedMilliaArray = result.groupNameCurrent
  142. .match(new RegExp(`^${MILLIA_PREFIX}\\${SHORT_MILLIA_DELIMITER}(\\d+)`));
  143. if (!matchedMilliaArray) {
  144. throw new InvalidTokenError(result.groupNameCurrent);
  145. }
  146. const [wholeMilliaPrefix, matchedMillia] = matchedMilliaArray;
  147. newMillia = Number(matchedMillia);
  148. prefix = wholeMilliaPrefix;
  149. } else {
  150. newMillia = result.milliaIndex + 1;
  151. prefix = MILLIA_PREFIX;
  152. }
  153. const oldMillia = result.milliaIndex;
  154. const newMillias = [...result.millias];
  155. newMillias[newMillia] = newMillias[oldMillia] || 1;
  156. newMillias[oldMillia] = 0;
  157. return {
  158. milliaIndex: newMillia,
  159. millias: newMillias,
  160. groupNameCurrent: result.groupNameCurrent.slice(prefix.length),
  161. done: false,
  162. };
  163. }
  164. throw new InvalidTokenError(result.groupNameCurrent);
  165. };
  166. /**
  167. * Gets the place of a group name (e.g. "million", "duodecillion", etc.).
  168. * @param groupName - The group name.
  169. * @returns bigint The place of the group name.
  170. */
  171. const getGroupPlaceFromGroupName = (groupName: string) => {
  172. if (groupName === THOUSAND) {
  173. return BigInt(1);
  174. }
  175. const groupNameBase = groupName.replace(ILLION_SUFFIX, '');
  176. const specialMillions = MILLIONS_SPECIAL_PREFIXES.findIndex((p) => groupNameBase === p);
  177. if (specialMillions > -1) {
  178. return BigInt(specialMillions + 1);
  179. }
  180. let result: DoParseState = {
  181. groupNameCurrent: groupNameBase,
  182. millias: [0],
  183. milliaIndex: 0,
  184. done: false,
  185. };
  186. do {
  187. result = doParseGroupName(result);
  188. } while (!result.done);
  189. const bigGroupPlace = BigInt(
  190. result.millias
  191. .map((s) => s.toString().padStart(3, '0'))
  192. .reverse()
  193. .join(''),
  194. );
  195. return bigGroupPlace + BigInt(1);
  196. };
  197. /**
  198. * Mode of the group parser.
  199. */
  200. enum ParseGroupsMode {
  201. /**
  202. * Initial mode.
  203. */
  204. INITIAL = 'initial',
  205. /**
  206. * Has parsed a ones name.
  207. */
  208. ONES_MODE = 'ones',
  209. /**
  210. * Has parsed a tens name.
  211. */
  212. TENS_MODE = 'tens',
  213. /**
  214. * Has parsed a ten-plus-ones name.
  215. */
  216. TEN_PLUS_ONES_MODE = 'tenPlusOnes',
  217. /**
  218. * Has parsed a "hundred" token.
  219. */
  220. HUNDRED_MODE = 'hundred',
  221. /**
  222. * Has parsed a "thousand" or any "-illion" token.
  223. */
  224. THOUSAND_MODE = 'thousand',
  225. /**
  226. * Done parsing.
  227. */
  228. DONE = 'done',
  229. }
  230. /**
  231. * State of the group parser.
  232. */
  233. interface ParserState {
  234. lastToken?: string;
  235. groups: Group[];
  236. mode: ParseGroupsMode;
  237. }
  238. const parseThousand = (acc: ParserState, token: string): ParserState => {
  239. const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE];
  240. if (acc.mode === ParseGroupsMode.ONES_MODE) {
  241. const ones = ONES.findIndex((o) => o === acc.lastToken);
  242. if (ones > -1) {
  243. lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 2)}${ones}`;
  244. }
  245. } else if (acc.mode === ParseGroupsMode.TENS_MODE) {
  246. const tens = TENS.findIndex((t) => t === acc.lastToken);
  247. if (tens > -1) {
  248. lastGroup[GROUP_DIGITS_INDEX] = (
  249. `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}`
  250. );
  251. }
  252. }
  253. // Put the digits in the right place.
  254. lastGroup[GROUP_PLACE_INDEX] = getGroupPlaceFromGroupName(token);
  255. return {
  256. ...acc,
  257. groups: [...acc.groups.slice(0, -1), lastGroup],
  258. lastToken: token,
  259. mode: ParseGroupsMode.THOUSAND_MODE,
  260. };
  261. };
  262. const parseHundred = (acc: ParserState): ParserState => {
  263. const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE];
  264. const hundreds = ONES.findIndex((o) => o === acc.lastToken);
  265. lastGroup[GROUP_DIGITS_INDEX] = `${hundreds}${lastGroup[GROUP_DIGITS_INDEX].slice(1)}`;
  266. return {
  267. ...acc,
  268. groups: [...acc.groups.slice(0, -1), lastGroup],
  269. mode: ParseGroupsMode.HUNDRED_MODE,
  270. };
  271. };
  272. const parseFinal = (acc: ParserState): ParserState => {
  273. const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE];
  274. if (acc.mode === ParseGroupsMode.ONES_MODE) {
  275. const ones = ONES.findIndex((o) => o === acc.lastToken);
  276. if (ones > -1) {
  277. lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 2)}${ones}`;
  278. }
  279. // We assume last token without parsed place will always be the smallest
  280. lastGroup[GROUP_PLACE_INDEX] = BigInt(0);
  281. return {
  282. ...acc,
  283. groups: [...acc.groups.slice(0, -1), lastGroup],
  284. mode: ParseGroupsMode.DONE,
  285. };
  286. }
  287. if (acc.mode === ParseGroupsMode.TENS_MODE) {
  288. const tens = TENS.findIndex((o) => o === acc.lastToken);
  289. if (tens > -1) {
  290. lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[0].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}`;
  291. }
  292. lastGroup[GROUP_PLACE_INDEX] = BigInt(0);
  293. return {
  294. ...acc,
  295. groups: [...acc.groups.slice(0, -1), lastGroup],
  296. mode: ParseGroupsMode.DONE,
  297. };
  298. }
  299. return acc;
  300. };
  301. const parseOnes = (acc: ParserState, token: string): ParserState => {
  302. if (acc.mode === ParseGroupsMode.THOUSAND_MODE) {
  303. // Create next empty place
  304. return {
  305. ...acc,
  306. lastToken: token,
  307. mode: ParseGroupsMode.ONES_MODE,
  308. groups: [...acc.groups, [...EMPTY_PLACE]],
  309. };
  310. }
  311. return {
  312. ...acc,
  313. lastToken: token,
  314. mode: ParseGroupsMode.ONES_MODE,
  315. };
  316. };
  317. const parseTenPlusOnes = (acc: ParserState, token: string): ParserState => {
  318. const tenPlusOnes = TEN_PLUS_ONES.findIndex((t) => t === token);
  319. const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE];
  320. if (acc.mode === ParseGroupsMode.THOUSAND_MODE) {
  321. return {
  322. ...acc,
  323. lastToken: token,
  324. mode: ParseGroupsMode.TEN_PLUS_ONES_MODE,
  325. groups: [...acc.groups, [`01${tenPlusOnes}`, lastGroup[GROUP_PLACE_INDEX] - BigInt(1)]],
  326. };
  327. }
  328. lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}1${tenPlusOnes}`;
  329. return {
  330. ...acc,
  331. lastToken: token,
  332. mode: ParseGroupsMode.TEN_PLUS_ONES_MODE,
  333. groups: [...acc.groups.slice(0, -1), lastGroup],
  334. };
  335. };
  336. const parseTens = (acc: ParserState, token: string): ParserState => {
  337. const tens = TENS.findIndex((t) => t === token);
  338. const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE];
  339. if (acc.mode === ParseGroupsMode.THOUSAND_MODE) {
  340. return {
  341. ...acc,
  342. lastToken: token,
  343. mode: ParseGroupsMode.TENS_MODE,
  344. groups: [...acc.groups, [`0${tens}0`, lastGroup[GROUP_PLACE_INDEX] - BigInt(1)]],
  345. };
  346. }
  347. lastGroup[GROUP_DIGITS_INDEX] = (
  348. `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}`
  349. );
  350. return {
  351. ...acc,
  352. lastToken: token,
  353. mode: ParseGroupsMode.TENS_MODE,
  354. groups: [...acc.groups.slice(0, -1), lastGroup],
  355. };
  356. };
  357. /**
  358. * Parses groups from a string.
  359. * @param tokens - The string to parse groups from.
  360. * @see {NumberNameSystem.stringifyGroups}
  361. * @returns Group[] The parsed groups.
  362. */
  363. export const parseGroups = (tokens: string[]) => {
  364. // We add a final token which is an empty string to parse whatever the last non-empty token is.
  365. const tokensToParse = [...tokens, FINAL_TOKEN];
  366. const { groups } = tokensToParse.reduce<ParserState>(
  367. (acc, token) => {
  368. if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) {
  369. return parseThousand(acc, token);
  370. }
  371. if (token === HUNDRED && acc.mode === ParseGroupsMode.ONES_MODE) {
  372. return parseHundred(acc);
  373. }
  374. if (token === FINAL_TOKEN) {
  375. return parseFinal(acc);
  376. }
  377. if (ONES.includes(token as OnesName)) {
  378. return parseOnes(acc, token);
  379. }
  380. if (TEN_PLUS_ONES.includes(token as TenPlusOnesName)) {
  381. return parseTenPlusOnes(acc, token);
  382. }
  383. if (TENS.includes(token as TensName)) {
  384. return parseTens(acc, token);
  385. }
  386. return {
  387. ...acc,
  388. lastToken: token,
  389. };
  390. },
  391. {
  392. lastToken: undefined,
  393. groups: [],
  394. mode: ParseGroupsMode.INITIAL,
  395. },
  396. );
  397. return groups;
  398. };
  399. /**
  400. * Combines groups into a string.
  401. * @param groups - The groups to combine.
  402. * @see {NumberNameSystem.splitIntoGroups}
  403. * @returns string The combined groups in exponential form.
  404. */
  405. export const combineGroups = (groups: Group[]) => {
  406. const places = groups.map((g) => g[GROUP_PLACE_INDEX]);
  407. if (places.length < 1) {
  408. return '';
  409. }
  410. const maxPlace = bigIntMax(...places) as bigint;
  411. const minPlace = bigIntMin(...places) as bigint;
  412. const firstGroup = groups.find((g) => g[GROUP_PLACE_INDEX] === maxPlace) ?? [...EMPTY_PLACE];
  413. const firstGroupPlace = firstGroup[GROUP_PLACE_INDEX];
  414. const groupsSorted = [];
  415. for (let i = maxPlace; i >= minPlace; i = BigInt(i) - BigInt(1)) {
  416. const thisGroup = groups.find((g) => g[GROUP_PLACE_INDEX] === i) ?? [EMPTY_GROUP_DIGITS, i];
  417. groupsSorted.push(thisGroup);
  418. }
  419. const digits = groupsSorted.reduce(
  420. (previousDigits, thisGroup) => {
  421. const [groupDigits] = thisGroup;
  422. return `${previousDigits}${groupDigits}`;
  423. },
  424. '',
  425. ).replace(/^0+/, '') || '0';
  426. const firstGroupDigits = firstGroup[0];
  427. const firstGroupDigitsWithoutZeroes = firstGroupDigits.replace(/^0+/, '');
  428. const exponentExtra = firstGroupDigits.length - firstGroupDigitsWithoutZeroes.length;
  429. const exponentValue = BigInt(
  430. (BigInt(firstGroupPlace) * BigInt(3)) + (BigInt(2) - BigInt(exponentExtra)),
  431. );
  432. const isExponentNegative = exponentValue < 0;
  433. const exponentValueAbs = isExponentNegative ? -exponentValue : exponentValue;
  434. const exponentSign = isExponentNegative ? NEGATIVE_SYMBOL : POSITIVE_SYMBOL;
  435. const exponent = `${exponentSign}${exponentValueAbs}`;
  436. const significandInteger = digits.slice(0, 1);
  437. const significandFraction = digits.slice(1).replace(/0+$/, '');
  438. if (significandFraction.length > 0) {
  439. return `${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`;
  440. }
  441. return `${significandInteger}${EXPONENT_DELIMITER}${exponent}`;
  442. };