util: group thousands in linear time

formatDecimal split digits with /(\d+?)(?=(\d{3})+(?!\d)|$)/g, whose
lookahead rescans the rest of a digit run at every position: quadratic in
the run's length (CodeQL js/polynomial-redos, high; inherited unchanged
from upstream). 20k digits took 258 ms, 200k about 25 s.

A loop now finds the same groups: within each digit run a group ends where
the digits left are a positive multiple of three, or at the end of the
string. Identical output to the pattern on 210,000 random inputs plus the
edge cases (letters around digits, short runs, negatives); 200k digits take
7 ms. The new spec case for a 60k-digit number fails on the old code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Satoshi Qazi MuhammedandClaude Opus 5.5 authored and SatoshiQaziMuhammed committed 2026-10-07 19:21:45 +04:00
1 parent ad3816da8e
commit edd1ea0870
2 files changed
+75 -6

No files matched your search

@@ -25,4 +25,21 @@ describe('formatDecimal', (): void => {
it('formats negative numbers', (): void => { it('formats negative numbers', (): void => {
expect(formatDecimal('-123456')).toEqual('-123,456'); 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');
});
}); });
+58 -6
View File
@@ -1,8 +1,62 @@
// Copyright 2017-2026 @pezkuwi/util authors & contributors // Copyright 2017-2026 @pezkuwi/util authors & contributors
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// eslint-disable-next-line prefer-regex-literals function isDigit (c: string): boolean {
const NUMBER_REGEX = new RegExp('(\\d+?)(?=(\\d{3})+(?!\\d)|$)', 'g'); 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 * @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 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 // we rather strip and re-add the negative sign in the output
const isNegative = value[0].startsWith('-'); const isNegative = value[0].startsWith('-');
const matched = isNegative const matched = groups(isNegative ? value.substring(1) : value);
? value.substring(1).match(NUMBER_REGEX)
: value.match(NUMBER_REGEX);
return matched return matched.length
? `${isNegative ? '-' : ''}${matched.join(separator)}` ? `${isNegative ? '-' : ''}${matched.join(separator)}`
: value; : value;
} }