mirror of
https://github.com/pezkuwichain/pezkuwi-common.git
synced 2026-10-20 18:56:18 +00:00
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:
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user