Prevention work surfaced a real latent bug: findAndReplace looped forever (OOM) on any non-global regex with a match — `match` was only reassigned inside `if (regex.global)`, so a non-global regex never advanced. Fixed by treating a non-global regex as a single match (`match = regex.global ? regex.exec(text) : null`) and added a regression test. Latent in practice (all current callers pass global regexes), but a crash waiting to happen. New suites (tsx + node:test), verified empirically: - utils/findAndReplace (10, incl. the regression) - utils/AsyncSearch (9): normalize + matchQuery (the timer-based class is skipped — needs window.performance/setTimeout, unavailable in node) - utils/ASCIILexicalTable (10): orderKeys gap-filling + invariants Full suite now 103 tests, all passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
32 lines
1.2 KiB
TypeScript
32 lines
1.2 KiB
TypeScript
export type ReplaceCallback<R> = (
|
|
match: RegExpExecArray | RegExpMatchArray,
|
|
pushIndex: number,
|
|
) => R;
|
|
export type ConvertPartCallback<R> = (text: string, pushIndex: number) => R;
|
|
|
|
export const findAndReplace = <ReplaceReturnType, ConvertReturnType>(
|
|
text: string,
|
|
regex: RegExp,
|
|
replace: ReplaceCallback<ReplaceReturnType>,
|
|
convertPart: ConvertPartCallback<ConvertReturnType>,
|
|
): Array<ReplaceReturnType | ConvertReturnType> => {
|
|
const result: Array<ReplaceReturnType | ConvertReturnType> = [];
|
|
let lastEnd = 0;
|
|
|
|
let match: RegExpExecArray | RegExpMatchArray | null = regex.exec(text);
|
|
while (match !== null && typeof match.index === 'number') {
|
|
result.push(convertPart(text.slice(lastEnd, match.index), result.length));
|
|
result.push(replace(match, result.length));
|
|
|
|
lastEnd = match.index + match[0].length;
|
|
// A non-global regex always returns the same first match from exec() (its
|
|
// lastIndex never advances), so re-running it would loop forever. Treat a
|
|
// non-global regex as a single match and stop after processing it.
|
|
match = regex.global ? regex.exec(text) : null;
|
|
}
|
|
|
|
result.push(convertPart(text.slice(lastEnd), result.length));
|
|
|
|
return result;
|
|
};
|