diff --git a/packages/util/src/format/formatDecimal.spec.ts b/packages/util/src/format/formatDecimal.spec.ts index 4e98402..cc59e17 100644 --- a/packages/util/src/format/formatDecimal.spec.ts +++ b/packages/util/src/format/formatDecimal.spec.ts @@ -25,4 +25,21 @@ describe('formatDecimal', (): void => { it('formats negative numbers', (): void => { expect(formatDecimal('-123456')).toEqual('-123,456'); }); + + // The pattern this replaced took quadratic time on a long digit run + // (CodeQL js/polynomial-redos): 60k digits took seconds; this is linear. + it('formats a very long number quickly', (): void => { + const start = Date.now(); + const out = formatDecimal(`1${'0'.repeat(60_000)}`); + + expect(Date.now() - start < 1000).toEqual(true); + expect(out.length).toEqual(60_001 + 20_000); + expect(out.startsWith('1,000,000')).toEqual(true); + }); + + it('keeps the grouping of the pattern it replaced', (): void => { + expect(formatDecimal('ab123cd')).toEqual('ab123cd'); + expect(formatDecimal('12a3456')).toEqual('3,456'); + expect(formatDecimal('1000000', '_')).toEqual('1_000_000'); + }); }); diff --git a/packages/util/src/format/formatDecimal.ts b/packages/util/src/format/formatDecimal.ts index be0606e..507b5d0 100644 --- a/packages/util/src/format/formatDecimal.ts +++ b/packages/util/src/format/formatDecimal.ts @@ -1,8 +1,62 @@ // Copyright 2017-2026 @pezkuwi/util authors & contributors // SPDX-License-Identifier: Apache-2.0 -// eslint-disable-next-line prefer-regex-literals -const NUMBER_REGEX = new RegExp('(\\d+?)(?=(\\d{3})+(?!\\d)|$)', 'g'); +function isDigit (c: string): boolean { + return c >= '0' && c <= '9'; +} + +/** + * Splits the digits of value into thousands groups, exactly as the pattern + * /(\d+?)(?=(\d{3})+(?!\d)|$)/g matched them, without its quadratic cost + * on long digit runs (CodeQL js/polynomial-redos): within each run of digits, + * a group ends where the digits left in the run are a positive multiple of + * three, or at the end of the string. A run that never reaches such a point + * (two digits followed by a letter, say) contributes nothing. + */ +function groups (value: string): string[] { + const out: string[] = []; + const end = value.length; + let i = 0; + + while (i < end) { + if (!isDigit(value[i])) { + i++; + continue; + } + + let j = i; + + while (j < end && isDigit(value[j])) { + j++; + } + + let start = i; + + while (start < j) { + let stop = -1; + + for (let q = start + 1; q <= j; q++) { + const left = j - q; + + if ((left > 0 && left % 3 === 0) || (left === 0 && j === end)) { + stop = q; + break; + } + } + + if (stop === -1) { + break; + } + + out.push(value.slice(start, stop)); + start = stop; + } + + i = j; + } + + return out; +} /** * @name formatDecimal @@ -12,11 +66,9 @@ export function formatDecimal (value: string, separator = ','): string { // We can do this by adjusting the regx, however for the sake of clarity // we rather strip and re-add the negative sign in the output const isNegative = value[0].startsWith('-'); - const matched = isNegative - ? value.substring(1).match(NUMBER_REGEX) - : value.match(NUMBER_REGEX); + const matched = groups(isNegative ? value.substring(1) : value); - return matched + return matched.length ? `${isNegative ? '-' : ''}${matched.join(separator)}` : value; }