From a70f90ab9a04756ceff9f1ca2c15ec8edc06dbd8 Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Thu, 3 Sep 2026 19:52:08 +0100 Subject: [PATCH] fix: indent content added with append/prepend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `indent()` walked the original characters and the edited chunk contents, but never looked at the `intro`/`outro` a chunk picks up from `appendLeft`, `appendRight`, `prependLeft` and `prependRight`. Two things went wrong: - a line that starts inside inserted content was never prefixed - a line break inside inserted content did not start a new line as far as the walk was concerned, so the *original* code after the insert lost its indent Inserted content is now indented in output order — chunk `intro`, then the content, then chunk `outro` — using the same helper as the string-level `intro`/`outro`, which also keeps the line-start tracking accurate across it. Two smaller fixes fall out of that: - the replacer only skipped the indent when it was continuing a line, but it applied that to every match, not just the one at offset 0; a match further in always follows a line break, so `s.append('\nZ')` left `Z` unindented - when a line starts at a chunk boundary the indent is appended to the intro rather than prepended, so it lands in front of the content instead of in front of an intro that has already been indented on its own --- README.md | 12 +++++ src/MagicString.ts | 43 ++++++++++----- test/MagicString.test.ts | 114 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a21810d..cdf77ac 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,18 @@ Indicates if the string has been changed. Prefixes each line of the string with `prefix`. If `prefix` is not supplied, the indentation will be guessed from the original content, falling back to a single tab character. Returns `this`. +Lines are counted in the generated string, so content added with `append*`/`prepend*` is indented too, and a line break inside that content starts a new line just like a line break in the original: + +```js +const s = new MagicString('var a = 1;\nvar b = 2;') + +s.prependRight(11, 'debugger;\n') +s.toString() // 'var a = 1;\ndebugger;\nvar b = 2;' + +s.indent(' ') +s.toString() // ' var a = 1;\n debugger;\n var b = 2;' +``` + The `options` argument can have an `exclude` property, which is an array of `[start, end]` character ranges. These ranges will be excluded from the indentation - useful for (e.g.) multiline strings. ### s.insertLeft( index, content ) diff --git a/src/MagicString.ts b/src/MagicString.ts index 753eb13..4c92e44 100644 --- a/src/MagicString.ts +++ b/src/MagicString.ts @@ -365,14 +365,23 @@ export default class MagicString { } let shouldIndentNextCharacter = options.indentStart !== false - const replacer = (match: string) => { - if (shouldIndentNextCharacter) - return `${resolvedIndentStr}${match}` - shouldIndentNextCharacter = true - return match + + // Indents every line break inside `str`, plus the very first character when + // `str` starts a line in the generated output. Any match after offset 0 + // necessarily follows a line break inside `str`, so it always gets indented. + const indentPiece = (str: string) => { + if (str === '') + return str + + const indented = str.replace(pattern, (match: string, offset: number) => + offset > 0 || shouldIndentNextCharacter ? `${resolvedIndentStr}${match}` : match) + + shouldIndentNextCharacter = str[str.length - 1] === '\n' + + return indented } - this.intro = this.intro.replace(pattern, replacer) + this.intro = indentPiece(this.intro) let charIndex = 0 let chunk = this.firstChunk @@ -381,7 +390,10 @@ export default class MagicString { shouldIndentNextCharacter = false if (index === chunk!.start) { - chunk!.prependRight(resolvedIndentStr) + // `appendRight` rather than `prependRight`, so that the indent lands + // directly in front of the content rather than in front of an intro + // that has already been indented in its own right + chunk!.appendRight(resolvedIndentStr) } else { this._splitChunk(chunk!, index) @@ -393,13 +405,14 @@ export default class MagicString { while (chunk) { const end = chunk.end + // content added with `appendRight`/`prependRight` is emitted before the + // chunk itself, so it has to be indented before the chunk is walked + if (!isExcluded[chunk.start]) + chunk.intro = indentPiece(chunk.intro) + if (chunk.edited) { if (!isExcluded[charIndex]) { - chunk.content = chunk.content.replace(pattern, replacer) - - if (chunk.content.length) { - shouldIndentNextCharacter = chunk.content[chunk.content.length - 1] === '\n' - } + chunk.content = indentPiece(chunk.content) } } else if (options.exclude) { @@ -446,11 +459,15 @@ export default class MagicString { } } + // ...and content added with `appendLeft`/`prependLeft` is emitted after it + if (!isExcluded[chunk.end - 1]) + chunk.outro = indentPiece(chunk.outro) + charIndex = chunk.end chunk = chunk.next } - this.outro = this.outro.replace(pattern, replacer) + this.outro = indentPiece(this.outro) return this } diff --git a/test/MagicString.test.ts b/test/MagicString.test.ts index dea8f2d..7959c30 100644 --- a/test/MagicString.test.ts +++ b/test/MagicString.test.ts @@ -764,6 +764,120 @@ describe('magicString', () => { assert.equal(s.toString(), '\tclass Foo extends Baz {}') }) + it('should indent content added with appendRight/prependRight', () => { + const s = new MagicString('a\nb\nc') + + s.appendRight(2, 'Q\n') + assert.equal(s.toString(), 'a\nQ\nb\nc') + + s.indent('>') + assert.equal(s.toString(), '>a\n>Q\n>b\n>c') + }) + + it('should indent content added with appendLeft/prependLeft', () => { + const s = new MagicString('a\nb\nc') + + s.appendLeft(1, '\nQ') + assert.equal(s.toString(), 'a\nQ\nb\nc') + + s.indent('>') + assert.equal(s.toString(), '>a\n>Q\n>b\n>c') + }) + + it('should indent a line that starts inside inserted content', () => { + const s = new MagicString('a\nb\nc') + + s.appendLeft(2, 'X') + assert.equal(s.toString(), 'a\nXb\nc') + + // the indent belongs in front of the insert, not between it and `b` + s.indent('>') + assert.equal(s.toString(), '>a\n>Xb\n>c') + }) + + it('should indent original content that follows a multiline insert', () => { + const s = new MagicString('a\nb\nc') + + s.prependRight(2, 'one\ntwo\n') + assert.equal(s.toString(), 'a\none\ntwo\nb\nc') + + s.indent('>') + assert.equal(s.toString(), '>a\n>one\n>two\n>b\n>c') + }) + + it('should not indent content that continues the current line', () => { + const s = new MagicString('a\nb\nc') + + s.appendRight(2, 'X') + assert.equal(s.toString(), 'a\nXb\nc') + + s.indent('>') + assert.equal(s.toString(), '>a\n>Xb\n>c') + }) + + it('should indent lines that start inside the outro', () => { + const s = new MagicString('a\nb\nc') + + s.append('\nZ') + assert.equal(s.toString(), 'a\nb\nc\nZ') + + s.indent('>') + assert.equal(s.toString(), '>a\n>b\n>c\n>Z') + }) + + it('should indent every line of a wrapped module', () => { + const s = new MagicString('var a = 1;\nvar b = 2;') + + s.prepend('(function () {\n') + s.prependRight(11, 'debugger;\n') + s.append('\n}());') + assert.equal( + s.toString(), + '(function () {\nvar a = 1;\ndebugger;\nvar b = 2;\n}());', + ) + + s.indent(' ') + assert.equal( + s.toString(), + ' (function () {\n var a = 1;\n debugger;\n var b = 2;\n }());', + ) + }) + + it('should indent every line of the generated string', () => { + const original = 'const a = 1;\nconst b = 2;\n\nexport { a, b };\n' + + const build = (s: MagicString) => { + s.prepend('// header\n') + s.appendLeft(12, '\nconst inserted = 3;') + s.overwrite(13, 25, 'const b = 20;\nconst c = 30;') + s.append('// footer\n') + } + + const generated = new MagicString(original) + build(generated) + + const indented = new MagicString(original) + build(indented) + indented.indent(' ') + + assert.equal( + indented.toString(), + generated + .toString() + .split('\n') + .map(line => (line === '' ? line : ` ${line}`)) + .join('\n'), + ) + }) + + it('should respect indentStart across inserted content', () => { + const s = new MagicString('a\nb') + + s.prependRight(0, 'X') + s.indent('>', { indentStart: false }) + assert.equal(s.toString(), 'Xa\n>b') + }) + it('should return this', () => { const s = new MagicString('abcdefghijkl') assert.strictEqual(s.indent(), s)