diff --git a/CHANGELOG.md b/CHANGELOG.md index 257d46d..6e5ef8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ # magic-string changelog +## 0.26.0 (unreleased) + +* Add a new method `MagicString.copy(start, end, index)`, which works like `.move()`, but +also keeps the original code in place. See Readme for caveats ([#193](https://github.com/Rich-Harris/magic-string/pull/193)) + ## 0.25.7 -* fix bundle mappings after remove and move in multiple sources ([#172](https://github.com/Rich-Harris/magic-string/issues/172)) +* Fix bundle mappings after remove and move in multiple sources ([#172](https://github.com/Rich-Harris/magic-string/issues/172)) ## 0.25.6 diff --git a/README.md b/README.md index 6115cba..730c9ca 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,16 @@ Appends the specified `content` at the `index` in the original string. If a rang Does what you'd expect. +### s.copy( start, end, index ) + +Copies the characters from `start` to `end` to `index`, keeping the original characters in place. + +Note a caveat: if you make any changes to the area you'll copy later (appendLeft/Right, overwrite), the changes are carried over to the new region as well. However, any changes you make afterwards are only made to the original area. + +Returns `this`. + +_Implementation detail: the created duplicate segments aren't added to the `byStart`/`byEnd` indexes - the original chunks stay there, so there's pretty much no way to address the created (copied) characters for appends etc. They are only reachable by the `.next`/`.previous` chains, and eventually as first/last chunk. Please don't do insane things and everything will keep working as you can expect._ + ### s.generateDecodedMap( options ) Generates a sourcemap object with raw mappings in array form, rather than encoded as a string. See `generateMap` documentation below for options details. Useful if you need to manipulate the sourcemap further, but most of the time you will use `generateMap` instead. diff --git a/index.d.ts b/index.d.ts index 251701c..cf5bc64 100644 --- a/index.d.ts +++ b/index.d.ts @@ -81,6 +81,7 @@ export default class MagicString { appendLeft(index: number, content: string): MagicString; appendRight(index: number, content: string): MagicString; clone(): MagicString; + copy(start: number, end: number, index: number): MagicString; generateMap(options?: Partial): SourceMap; generateDecodedMap(options?: Partial): DecodedSourceMap; getIndentString(): string; diff --git a/package.json b/package.json index 957b8f9..1416c03 100644 --- a/package.json +++ b/package.json @@ -32,13 +32,13 @@ ], "scripts": { "test": "mocha", - "pretest": "npm run lint && npm run build", + "pretest": "npm run lint && npm run build -- --environment DEBUG", "format": "prettier --single-quote --print-width 100 --use-tabs --write src/*.js src/**/*.js", "build": "rollup -c", "prepare": "npm run build", "prepublishOnly": "rm -rf dist && npm test", "lint": "eslint src test", - "watch": "rollup -cw" + "watch": "rollup -cw --environment DEBUG" }, "files": [ "dist/*", diff --git a/rollup.config.js b/rollup.config.js index fc867da..338a1bc 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -5,7 +5,7 @@ import replace from 'rollup-plugin-replace'; const plugins = [ buble({ exclude: 'node_modules/**' }), nodeResolve(), - replace({ DEBUG: false }) + replace({ DEBUG: !!process.env.DEBUG }) ]; export default [ diff --git a/src/MagicString.js b/src/MagicString.js index 14f5cb2..0d9627c 100644 --- a/src/MagicString.js +++ b/src/MagicString.js @@ -311,9 +311,9 @@ export default class MagicString { if (newLeft) newLeft.next = first; if (newRight) newRight.previous = last; - if (!first.previous) this.firstChunk = last.next; + if (!first.previous) this.firstChunk = oldRight; if (!last.next) { - this.lastChunk = first.previous; + this.lastChunk = oldLeft; this.lastChunk.next = null; } @@ -327,6 +327,57 @@ export default class MagicString { return this; } + copy(start, end, index) { + if (DEBUG) this.stats.time('copy'); + + this._split(start); + this._split(end); + this._split(index); + + const first = this.byStart[start]; + const last = this.byEnd[end]; + + const newRight = this.byStart[index]; + if (!newRight && last === this.lastChunk) return this; + const newLeft = newRight ? newRight.previous : this.lastChunk; + + const duplicates = [first.clone()]; + if (first !== last) { + let lastOld = first; + let lastDuped = duplicates[duplicates.length - 1]; + while (true) { + const nextOld = lastOld.next; + const nextDuped = nextOld.clone(); + + lastDuped.next = nextDuped; + nextDuped.previous = lastDuped; + + duplicates.push(nextDuped); + + if (nextOld === last) break; + lastOld = nextOld; + lastDuped = nextDuped; + } + } + if (DEBUG) { + duplicates.forEach(dupe => dupe.isCopy = true); + } + const newFirst = duplicates[0]; + const newLast = duplicates[duplicates.length - 1]; + + if (newLeft) newLeft.next = newFirst; + newFirst.previous = newLeft; + + if (newRight) newRight.previous = newLast; + newLast.next = newRight || null; + + if (!newLeft) this.firstChunk = newFirst; + if (!newRight) this.lastChunk = newLast; + + if (DEBUG) this.stats.timeEnd('copy'); + return this; + } + overwrite(start, end, content, options) { if (typeof content !== 'string') throw new TypeError('replacement content must be a string'); diff --git a/test/MagicString.js b/test/MagicString.js index 3d290a5..de31fc3 100644 --- a/test/MagicString.js +++ b/test/MagicString.js @@ -721,6 +721,130 @@ describe('MagicString', () => { }); }); + describe('copy', () => { + it('copies characters', () => { + const s = new MagicString('abcDEFghijkl'); + + s.copy(3, 6, 9); + assert.equal(s.toString(), 'abcDEFghiDEFjkl'); + }); + + it('copies to the beginning', () => { + const s = new MagicString('abcDEFghijkl'); + + s.copy(3, 6, 0); + assert.equal(s.toString(), 'DEFabcDEFghijkl'); + }); + + it('copies to the end', () => { + const s = new MagicString('abcDEFghijkl'); + + s.copy(3, 6, 12); + assert.equal(s.toString(), 'abcDEFghijklDEF'); + }); + + it('allows pasting selection into itself', () => { + const s = new MagicString('abcDEFghijkl'); + + s.copy(3, 6, 4); + assert.equal(s.toString(), 'abcDDEFEFghijkl'); + }); + + it('puts multiple insertions in the same place in the order they were inserted', () => { + const s = new MagicString('ABcDEfghijkl'); + + s.copy(3, 5, 9); + s.copy(0, 2, 9); + assert.equal(s.toString(), 'ABcDEfghiDEABjkl'); + }); + + it('carries over append made beforehand at beginning of selection', () => { + const s = new MagicString('abcDEFghijkl'); + + s.appendRight(3, 'x'); + s.copy(3, 6, 9); + assert.equal(s.toString(), 'abcxDEFghixDEFjkl'); + }); + + it('carries over append made beforehand at end of selection', () => { + const s = new MagicString('abcDEFghijkl'); + + s.appendLeft(6, 'x'); + s.copy(3, 6, 9); + assert.equal(s.toString(), 'abcDEFxghiDEFxjkl'); + }); + + it('carries over overwrite made beforehand in middle of selection', () => { + const s = new MagicString('abcDEFghijkl'); + + s.overwrite(4, 5, 'xy'); + s.copy(3, 6, 9); + assert.equal(s.toString(), 'abcDxyFghiDxyFjkl'); + }); + + it('does not carry over changes made after copy', () => { + const s = new MagicString('abcDEFghijkl'); + + s.copy(3, 6, 9); + s.overwrite(4, 5, 'xy'); + s.appendRight(4, 'a'); + s.appendLeft(5, 'b'); + assert.equal(s.toString(), 'abcDaxybFghiDEFjkl'); + }); + + it('does not carry over changes next to the selection', () => { + const s = new MagicString('abcDEFghijkl'); + + s.appendRight(6, 'x'); + s.overwrite(2, 3, 'foo'); + s.appendLeft(3, 'y'); + s.copy(3, 6, 9); + assert.equal(s.toString(), 'abfooyDEFxghiDEFjkl'); + }); + + it('cannot insert into an overwritten area', () => { + const s = new MagicString('abcDEFghijkl'); + + s.overwrite(8, 10, 'foo'); + assert.throws(() => s.copy(3, 6, 9), /Cannot split a chunk that has already been edited/); + }); + + it('cannot copy part of overwritten area', () => { + const s = new MagicString('abcDEFghijkl'); + + s.overwrite(2, 5, 'foo'); + assert.throws(() => s.copy(3, 6, 9), /Cannot split a chunk that has already been edited/); + }); + + it('cannot overwrite area where something was inserted', () => { + const s = new MagicString('abcDEFghijkl'); + + s.copy(3, 6, 9); + assert.throws(() => s.overwrite(8, 10, 'foo'), /Cannot overwrite across a split point/); + }); + + it('can overwrite area from where something was copied', () => { + const s = new MagicString('abcDEFghijkl'); + + s.copy(3, 6, 9); + s.overwrite(2, 5, 'foo'); + assert.equal(s.toString(), 'abfooFghiDEFjkl'); + }); + + it('can surround inserted area by copy region', () => { + const s = new MagicString('abcDEFghijkl'); + + s.copy(3, 6, 9); + s.copy(8, 10, 11); + assert.equal(s.toString(), 'abcDEFghiDEFjkiDEFjl'); + }); + + it('returns this', () => { + const s = new MagicString('abcdefghijkl'); + assert.strictEqual(s.copy(3, 6, 9), s); + }); + }); + describe('overwrite', () => { it('should replace characters', () => { const s = new MagicString('abcdefghijkl'); diff --git a/test/utils/IntegrityCheckingMagicString.js b/test/utils/IntegrityCheckingMagicString.js index 7ac9fc6..8c6dc4a 100644 --- a/test/utils/IntegrityCheckingMagicString.js +++ b/test/utils/IntegrityCheckingMagicString.js @@ -7,15 +7,17 @@ class IntegrityCheckingMagicString extends MagicString { let chunk = this.firstChunk; let numNodes = 0; while (chunk) { - assert.strictEqual(this.byStart[chunk.start], chunk); - assert.strictEqual(this.byEnd[chunk.end], chunk); - assert.strictEqual(chunk.previous, prevChunk); - if (prevChunk) { - assert.strictEqual(prevChunk.next, chunk); + if (!chunk.isCopy) { + assert.strictEqual(this.byStart[chunk.start], chunk); + assert.strictEqual(this.byEnd[chunk.end], chunk); + assert.strictEqual(chunk.previous, prevChunk); + if (prevChunk) { + assert.strictEqual(prevChunk.next, chunk); + } + numNodes++; } prevChunk = chunk; chunk = chunk.next; - numNodes++; } assert.strictEqual(prevChunk, this.lastChunk); assert.strictEqual(this.lastChunk.next, null);