fix: handle empty matches in `replace` and `replaceAll` (#335) · Rich-Harris/magic-string@b6a53f7 · GitHub
Skip to content

Commit b6a53f7

Browse files
authored
fix: handle empty matches in replace and replaceAll (#335)
1 parent 9391bb7 commit b6a53f7

3 files changed

Lines changed: 122 additions & 24 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions

src/MagicString.ts

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,35 +1222,40 @@ export default class MagicString {
12221222
return replacement(match[0], ...match.slice(1), match.index, str, match.groups)
12231223
}
12241224
}
1225-
function matchAll(re: RegExp, str: string): RegExpExecArray[] {
1226-
const matches = []
1227-
while (true) {
1228-
const match = re.exec(str)
1229-
if (!match)
1230-
break
1231-
1232-
matches.push(match)
1225+
const replaceMatch = (match: RegExpMatchArray): void => {
1226+
if (match.index == null)
1227+
return
1228+
1229+
const replacement = getReplacement(match, this.original)
1230+
if (replacement === match[0])
1231+
return
1232+
1233+
if (match[0].length === 0) {
1234+
// a zero-length match spans no characters, so there is no range to
1235+
// overwrite - the replacement is an insertion at the matched position,
1236+
// which is what `String.prototype.replace` does for an empty match
1237+
this.appendRight(match.index, replacement)
1238+
}
1239+
else {
1240+
this.overwrite(match.index, match.index + match[0].length, replacement)
12331241
}
1234-
return matches
12351242
}
1243+
12361244
if (searchValue.global) {
1237-
const matches = matchAll(searchValue, this.original)
1238-
matches.forEach((match) => {
1239-
if (match.index != null) {
1240-
const replacement = getReplacement(match, this.original)
1241-
if (replacement !== match[0]) {
1242-
this.overwrite(match.index, match.index + match[0].length, replacement)
1243-
}
1244-
}
1245-
})
1245+
// `String.prototype.replace` starts a global regexp from the beginning of
1246+
// the string, so reset `lastIndex` - a regexp that has already been used
1247+
// would otherwise resume from wherever it stopped and skip earlier matches.
1248+
// `matchAll` also steps over a zero-length match, where `exec` in a loop
1249+
// would keep rematching it at an unmoving `lastIndex` and never terminate.
1250+
searchValue.lastIndex = 0
1251+
for (const match of this.original.matchAll(searchValue)) {
1252+
replaceMatch(match)
1253+
}
12461254
}
12471255
else {
12481256
const match = this.original.match(searchValue)
1249-
if (match && match.index != null) {
1250-
const replacement = getReplacement(match, this.original)
1251-
if (replacement !== match[0]) {
1252-
this.overwrite(match.index, match.index + match[0].length, replacement)
1253-
}
1257+
if (match) {
1258+
replaceMatch(match)
12541259
}
12551260
}
12561261
return this
@@ -1266,7 +1271,15 @@ export default class MagicString {
12661271
replacement = replacement(string, index, original)
12671272
}
12681273
if (string !== replacement) {
1269-
this.overwrite(index, index + string.length, replacement)
1274+
if (string.length === 0) {
1275+
// an empty search string matches the empty range at the start of the
1276+
// string, which has no characters to overwrite - the replacement is an
1277+
// insertion there, as it is for `String.prototype.replace`
1278+
this.appendRight(index, replacement)
1279+
}
1280+
else {
1281+
this.overwrite(index, index + string.length, replacement)
1282+
}
12701283
}
12711284
}
12721285

@@ -1288,6 +1301,21 @@ export default class MagicString {
12881301
_replaceAllString(string: string, replacement: string | ReplacementFunction): this {
12891302
const { original } = this
12901303
const stringLength = string.length
1304+
1305+
// an empty search string matches the empty range before every character plus
1306+
// one at the end, and `indexOf` clamps its start index to the string length,
1307+
// so it can neither find those ranges nor ever report -1 - step through them
1308+
if (stringLength === 0) {
1309+
for (let index = 0; index <= original.length; index += 1) {
1310+
const _replacement
1311+
= typeof replacement === 'function' ? replacement('', index, original) : replacement
1312+
if (_replacement !== '')
1313+
this.appendRight(index, _replacement)
1314+
}
1315+
1316+
return this
1317+
}
1318+
12911319
for (
12921320
let index = original.indexOf(string);
12931321
index !== -1;

test/MagicString.test.ts

Lines changed: 68 additions & 0 deletions

0 commit comments

Comments
 (0)