Skip to content

Commit 7f7f9c5

Browse files
fix: handle negative indices, overlapping ranges, and moved content in MagicString remove (#8829)
## Summary - Rewrote MagicString::remove to iterate by original position (chunk_by_start) instead of linked-list order, fixing incorrect behavior with moved content - Added support for negative indices in remove() by accepting i64 and using normalize_index (matching reset/slice pattern) - Fixed overlapping range removal — previously errored, now correctly handles partially/fully overlapping removes - Zero-length removals (e.g., remove(0, 0) or remove(9, -3) where indices resolve to the same position) are now treated as no-ops ## Test plan - Unskipped 8 previously-skipped tests in MagicString.test.ts: - should treat zero-length removals as a no-op - should remove overlapping ranges - should remove overlapping ranges, redux - should remove modified ranges - should remove interior inserts - removes across moved content - should accept negative indices - should throw error when using negative indices with empty string - Updated offset underflow test expectation to match new error message 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 050cd43 commit 7f7f9c5

File tree

4 files changed

+54
-24
lines changed

4 files changed

+54
-24
lines changed

crates/rolldown_binding/src/types/binding_magic_string.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -744,15 +744,29 @@ impl BindingMagicString<'_> {
744744
}
745745

746746
#[napi]
747-
pub fn remove<'s>(&'s mut self, this: This<'s>, start: u32, end: u32) -> napi::Result<This<'s>> {
747+
pub fn remove<'s>(&'s mut self, this: This<'s>, start: i64, end: i64) -> napi::Result<This<'s>> {
748+
// Apply offset, then handle negative indices (matching reset/slice pattern)
749+
let start = self.utf16_to_byte_mapper.normalize_index(self.apply_offset_i64(start));
750+
let end = self.utf16_to_byte_mapper.normalize_index(self.apply_offset_i64(end));
751+
752+
if start == end {
753+
return Ok(this);
754+
}
755+
756+
// Convert character indices to byte indices
757+
// indices are non-negative after normalize_index and files are < 4GB
758+
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
748759
let start_byte = self
749760
.utf16_to_byte_mapper
750-
.utf16_to_byte(self.apply_offset_u32(start)?)
751-
.ok_or_else(|| napi::Error::from_reason("Invalid start character index"))?;
761+
.utf16_to_byte(start as u32)
762+
.ok_or_else(|| napi::Error::from_reason("Character is out of bounds"))?;
763+
764+
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
752765
let end_byte = self
753766
.utf16_to_byte_mapper
754-
.utf16_to_byte(self.apply_offset_u32(end)?)
755-
.ok_or_else(|| napi::Error::from_reason("Invalid end character index"))?;
767+
.utf16_to_byte(end as u32)
768+
.ok_or_else(|| napi::Error::from_reason("Character is out of bounds"))?;
769+
756770
self.inner.remove(start_byte, end_byte).map_err(napi::Error::from_reason)?;
757771
Ok(this)
758772
}

crates/string_wizard/src/magic_string/movement.rs

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
1-
use crate::MagicString;
2-
3-
use super::update::UpdateOptions;
1+
use crate::{MagicString, chunk::EditOptions};
42

53
impl MagicString<'_> {
4+
/// Removes characters in the range `[start, end)` from the generated output.
5+
///
6+
/// Unlike `update`/`overwrite`, this iterates by original position (via `chunk_by_start`)
7+
/// rather than by linked-list order, so it works correctly across moved content.
68
pub fn remove(&mut self, start: u32, end: u32) -> Result<&mut Self, String> {
7-
self.inner_update_with(
8-
start,
9-
end,
10-
"".into(),
11-
UpdateOptions { keep_original: false, overwrite: true },
12-
false,
13-
)
9+
if start == end {
10+
return Ok(self);
11+
}
12+
if start > end {
13+
return Err(format!("end must be greater than start, got start: {start}, end: {end}"));
14+
}
15+
16+
self.split_at(start)?;
17+
self.split_at(end)?;
18+
19+
let mut chunk_idx = self.chunk_by_start.get(&start).copied();
20+
21+
while let Some(idx) = chunk_idx {
22+
let chunk = &mut self.chunks[idx];
23+
let chunk_end = chunk.end();
24+
chunk.edit("".into(), EditOptions { overwrite: true, store_name: false });
25+
26+
chunk_idx = if end > chunk_end { self.chunk_by_start.get(&chunk_end).copied() } else { None };
27+
}
28+
29+
Ok(self)
1430
}
1531

1632
/// Moves the characters from start and end to index. Returns this.

packages/rolldown/tests/magic-string/MagicString.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,14 +1261,14 @@ describe('MagicString', () => {
12611261
assert.equal(s.toString(), 'abcdef');
12621262
});
12631263

1264-
it.skip('should treat zero-length removals as a no-op', () => {
1264+
it('should treat zero-length removals as a no-op', () => {
12651265
const s = new MagicString('abcdefghijkl');
12661266

12671267
s.remove(0, 0).remove(6, 6).remove(9, -3);
12681268
assert.equal(s.toString(), 'abcdefghijkl');
12691269
});
12701270

1271-
it.skip('should remove overlapping ranges', () => {
1271+
it('should remove overlapping ranges', () => {
12721272
const s1 = new MagicString('abcdefghijkl');
12731273

12741274
s1.remove(3, 7).remove(5, 9);
@@ -1280,15 +1280,15 @@ describe('MagicString', () => {
12801280
assert.equal(s2.toString(), 'abchijkl');
12811281
});
12821282

1283-
it.skip('should remove overlapping ranges, redux', () => {
1283+
it('should remove overlapping ranges, redux', () => {
12841284
const s = new MagicString('abccde');
12851285

12861286
s.remove(2, 3); // c
12871287
s.remove(1, 3); // bc
12881288
assert.equal(s.toString(), 'acde');
12891289
});
12901290

1291-
it.skip('should remove modified ranges', () => {
1291+
it('should remove modified ranges', () => {
12921292
const s = new MagicString('abcdefghi');
12931293

12941294
s.overwrite(3, 6, 'DEF');
@@ -1306,7 +1306,7 @@ describe('MagicString', () => {
13061306
assert.equal(s.toString(), '(ab);');
13071307
});
13081308

1309-
it.skip('should remove interior inserts', () => {
1309+
it('should remove interior inserts', () => {
13101310
const s = new MagicString('abc;');
13111311

13121312
s.appendLeft(1, '[');
@@ -1330,7 +1330,7 @@ describe('MagicString', () => {
13301330
assert.strictEqual(s.remove(3, 4), s);
13311331
});
13321332

1333-
it.skip('removes across moved content', () => {
1333+
it('removes across moved content', () => {
13341334
const s = new MagicString('abcdefghijkl');
13351335

13361336
s.move(6, 9, 3);
@@ -1339,15 +1339,15 @@ describe('MagicString', () => {
13391339
assert.equal(s.toString(), 'abchidejkl');
13401340
});
13411341

1342-
it.skip('should accept negative indices', () => {
1342+
it('should accept negative indices', () => {
13431343
const s = new MagicString('abcde');
13441344
// "abcde"
13451345
// ^
13461346
s.remove(-2, -1);
13471347
assert.equal(s.toString(), 'abce');
13481348
});
13491349

1350-
it.skip('should throw error when using negative indices with empty string', () => {
1350+
it('should throw error when using negative indices with empty string', () => {
13511351
const s = new MagicString('');
13521352
assert.throws(() => s.remove(-2, -1), /Error: Character is out of bounds/);
13531353
});

packages/rolldown/tests/magic-string/rolldown-magic-string.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ describe('offset', () => {
1111
describe('underflow guard — negative (index + offset) must throw, not panic', () => {
1212
it('remove() throws when offset causes index underflow', () => {
1313
const s = new MagicString('hello world', { offset: -1 });
14-
assert.throws(() => s.remove(0, 1), /out of bounds/);
14+
assert.throws(() => s.remove(0, 1), /end must be greater than start/);
1515
});
1616

1717
it('prependLeft() throws when offset causes index underflow', () => {

0 commit comments

Comments
 (0)