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
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,8 @@ The differences from [`String.replace`](<(https://developer.mozilla.org/en-US/do
239239

240240
- It will always match against the **original string**
241241
- It mutates the magic string state (use `.clone()` to be immutable)
242+
- A zero-length match spans no characters, so there is nothing to overwrite - the
243+
substitution is inserted at the matched position, as if by `s.appendRight(index, substitution)`
242244

243245
### s.replaceAll( regexpOrString, substitution )
244246

‎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
Original file line numberDiff line numberDiff line change
@@ -2121,6 +2121,27 @@ describe('magicString', () => {
21212121

21222122
assert.strictEqual(s.firstChunk, s.lastChunk)
21232123
})
2124+
2125+
it('should insert at a zero-length match instead of overwriting nothing', () => {
2126+
// an empty match spans no characters, so there is no range to overwrite -
2127+
// `String.prototype.replace` inserts at the matched position
2128+
assert.strictEqual(new MagicString('abc').replace(/x?/, 'Y').toString(), 'Yabc')
2129+
assert.strictEqual(new MagicString('abc').replace('', 'X').toString(), 'Xabc')
2130+
})
2131+
2132+
it('should terminate on a global regexp that matches the empty string', () => {
2133+
assert.strictEqual(new MagicString('bab').replace(/a*/g, 'X').toString(), 'XbXXbX')
2134+
assert.strictEqual(new MagicString('a b').replace(/\s*/g, '_').toString(), '_a__b_')
2135+
assert.strictEqual(new MagicString('axb').replace(/x?/g, 'Y').toString(), 'YaYYbY')
2136+
})
2137+
2138+
it('should start a global regexp from the beginning of the string', () => {
2139+
const re = /o/g
2140+
re.exec('foo') // leaves lastIndex at 2
2141+
2142+
assert.strictEqual(new MagicString('foo').replace(re, 'X').toString(), 'fXX')
2143+
assert.strictEqual(re.lastIndex, 0)
2144+
})
21242145
})
21252146

21262147
describe('replaceAll', () => {
@@ -2202,5 +2223,52 @@ describe('magicString', () => {
22022223
assert.strictEqual(s1.slice(), 'ello world')
22032224
assert.equal(s1.move(0, 1, 2).slice(0), 'elo world')
22042225
})
2226+
2227+
it('should insert at every zero-length match', () => {
2228+
assert.strictEqual(
2229+
new MagicString('a\nb\nc').replaceAll(/^/gm, '// ').toString(),
2230+
'// a\n// b\n// c',
2231+
)
2232+
assert.strictEqual(new MagicString('a\nb').replaceAll(/$/gm, ';').toString(), 'a;\nb;')
2233+
assert.strictEqual(new MagicString('ab cd').replaceAll(/\b/g, '|').toString(), '|ab| |cd|')
2234+
assert.strictEqual(new MagicString('abc').replaceAll(/x*/g, '-').toString(), '-a-b-c-')
2235+
assert.strictEqual(new MagicString('abc').replaceAll('', '-').toString(), '-a-b-c-')
2236+
})
2237+
2238+
it('should step over a whole code point for a unicode-aware regexp', () => {
2239+
// without the `u` flag the surrogate halves are matched between, as they are
2240+
// by `String.prototype.replaceAll`
2241+
const emoji = '\u{1F600}'
2242+
2243+
assert.strictEqual(
2244+
new MagicString(`a${emoji}b`).replaceAll(/x*/gu, '.').toString(),
2245+
`.a.${emoji}.b.`,
2246+
)
2247+
assert.strictEqual(
2248+
new MagicString(`a${emoji}b`).replaceAll(/x*/g, '.').toString(),
2249+
`.a.${emoji[0]}.${emoji[1]}.b.`,
2250+
)
2251+
})
2252+
2253+
it('should report the index of every empty-string match to a replacer', () => {
2254+
const indexes: number[] = []
2255+
const s = new MagicString('ab').replaceAll('', (_match, index) => {
2256+
indexes.push(index)
2257+
return `<${index}>`
2258+
})
2259+
2260+
assert.strictEqual(s.toString(), '<0>a<1>b<2>')
2261+
assert.deepEqual(indexes, [0, 1, 2])
2262+
})
2263+
2264+
it('should leave the original alone when an empty match is replaced by itself', () => {
2265+
const s = new MagicString('abc')
2266+
2267+
s.replaceAll(/x*/g, '')
2268+
s.replaceAll('', '')
2269+
2270+
assert.strictEqual(s.toString(), 'abc')
2271+
assert.strictEqual(s.hasChanged(), false)
2272+
})
22052273
})
22062274
})

0 commit comments

Comments
 (0)