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)