Skip to content

fix: treat moving a range to where it already sits as a no-op - #347

Open
wahidrizka wants to merge 1 commit into
Rich-Harris:masterfrom
wahidrizka:fix-move-in-place
Open

fix: treat moving a range to where it already sits as a no-op#347
wahidrizka wants to merge 1 commit into
Rich-Harris:masterfrom
wahidrizka:fix-move-in-place

Conversation

@wahidrizka

Copy link
Copy Markdown
Contributor

move() returns early when the range is already the last chunk and the target is the end of the string. It has no such check when an earlier move has already put the range right before the target index, which is also a no-op, so it splices the range in next to itself.

import MagicString from 'magic-string'

const s = new MagicString('abcd')
s.move(3, 4, 1) // "adbc"
s.move(3, 4, 1) // nothing to do, "d" is already right before "b"
s.toString()    // "abc" before this fix ("d" is gone), "adbc" after

When the range is at the very start instead, the text survives but the first chunk becomes its own previous. toString() walks forwards, so it looks fine, but anything that walks backwards never reaches the start:

const s = new MagicString('xb')
s.move(1, 2, 0)
s.move(1, 2, 0)
s.lastLine() // never returns, and runs until the process is out of memory

lastChar() and trimEnd() get stuck the same way when the chunks they walk back over are empty or all whitespace.

The fix adds the missing early return next to the existing one: if the chunk at index is already preceded by the range's last chunk, there is nothing to move. This is separate from the case the hasMovedChunks walk handles (a range an earlier move has split), which is caught before this point and still throws.

To check this is the only way move() goes wrong here, I ran random sequences of moves against a small reference model (a list of original positions, where a move takes the run out and puts it back before the target). Before the fix, all 4,916 divergences in 50,000 sequences happened at a step the model says changes nothing, and none at a real move. After the fix there are none in 200,000 sequences (about 287,000 valid moves).

Both new tests fail on the old code and pass now. The suite goes from 366 to 368, and tsc (both configs) and eslint are clean.

A side note on the tests: IntegrityCheckingMagicString is meant to run checkIntegrity() after every call, which would have caught this, but it looks up methods with for...in, and class methods are not enumerable, so it wraps none of the 43. The new tests call checkIntegrity() directly so that on the old code they fail right away instead of hanging. With the loop switched to Object.getOwnPropertyNames, all 43 get wrapped and the full suite passes on this branch, so I can send that as a separate PR if you'd like.

move() already returns early when the range is the last chunk and the
target is the end of the string, but it had no such check when an
earlier move had already put the range right before the target index.
The splice then made the range its own neighbour: the range vanished
from toString() unless it was at the very start, and the chunk list got
a backwards loop that could make lastChar(), lastLine() and trimEnd()
run forever. It now returns early in that case too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant