diff --git a/goldens/public-api/angular_devkit/schematics/src/index.md b/goldens/public-api/angular_devkit/schematics/src/index.md index 3864a36d18e9..8f359142a890 100644 --- a/goldens/public-api/angular_devkit/schematics/src/index.md +++ b/goldens/public-api/angular_devkit/schematics/src/index.md @@ -488,13 +488,13 @@ export class HostSink extends SimpleSinkBase { // (undocumented) _done(): Observable; // (undocumented) - protected _filesToCreate: Map; + protected _filesToCreate: Map; // (undocumented) protected _filesToDelete: Set; // (undocumented) protected _filesToRename: Set<[Path, Path]>; // (undocumented) - protected _filesToUpdate: Map; + protected _filesToUpdate: Map; // (undocumented) protected _force: boolean; // (undocumented) diff --git a/packages/angular_devkit/schematics/BUILD.bazel b/packages/angular_devkit/schematics/BUILD.bazel index a71c8d45a3a9..c108c65eff5f 100644 --- a/packages/angular_devkit/schematics/BUILD.bazel +++ b/packages/angular_devkit/schematics/BUILD.bazel @@ -41,6 +41,7 @@ ts_library( "//packages/angular_devkit/core", "//packages/angular_devkit/core/node", # TODO: get rid of this for 6.0 "@npm//@types/node", + "@npm//magic-string", "@npm//rxjs", ], ) diff --git a/packages/angular_devkit/schematics/package.json b/packages/angular_devkit/schematics/package.json index fe5cf86d705c..c02f62cbad53 100644 --- a/packages/angular_devkit/schematics/package.json +++ b/packages/angular_devkit/schematics/package.json @@ -15,6 +15,7 @@ "dependencies": { "@angular-devkit/core": "0.0.0", "jsonc-parser": "3.0.0", + "magic-string": "0.25.7", "ora": "5.4.1", "rxjs": "6.6.7" } diff --git a/packages/angular_devkit/schematics/src/sink/host.ts b/packages/angular_devkit/schematics/src/sink/host.ts index 970156211bdd..bcb193dea207 100644 --- a/packages/angular_devkit/schematics/src/sink/host.ts +++ b/packages/angular_devkit/schematics/src/sink/host.ts @@ -16,14 +16,14 @@ import { } from 'rxjs'; import { concatMap, reduce } from 'rxjs/operators'; import { CreateFileAction } from '../tree/action'; -import { UpdateBuffer } from '../utility/update-buffer'; +import { UpdateBufferBase } from '../utility/update-buffer'; import { SimpleSinkBase } from './sink'; export class HostSink extends SimpleSinkBase { protected _filesToDelete = new Set(); protected _filesToRename = new Set<[Path, Path]>(); - protected _filesToCreate = new Map(); - protected _filesToUpdate = new Map(); + protected _filesToCreate = new Map(); + protected _filesToUpdate = new Map(); constructor(protected _host: virtualFs.Host, protected _force = false) { super(); @@ -55,12 +55,12 @@ export class HostSink extends SimpleSinkBase { } protected _overwriteFile(path: Path, content: Buffer): Observable { - this._filesToUpdate.set(path, new UpdateBuffer(content)); + this._filesToUpdate.set(path, UpdateBufferBase.create(content)); return EMPTY; } protected _createFile(path: Path, content: Buffer): Observable { - this._filesToCreate.set(path, new UpdateBuffer(content)); + this._filesToCreate.set(path, UpdateBufferBase.create(content)); return EMPTY; } diff --git a/packages/angular_devkit/schematics/src/tree/recorder.ts b/packages/angular_devkit/schematics/src/tree/recorder.ts index 9eb4b4da5805..236996306dce 100644 --- a/packages/angular_devkit/schematics/src/tree/recorder.ts +++ b/packages/angular_devkit/schematics/src/tree/recorder.ts @@ -7,17 +7,17 @@ */ import { ContentHasMutatedException } from '../exception/exception'; -import { UpdateBuffer } from '../utility/update-buffer'; +import { UpdateBufferBase } from '../utility/update-buffer'; import { FileEntry, UpdateRecorder } from './interface'; export class UpdateRecorderBase implements UpdateRecorder { protected _path: string; protected _original: Buffer; - protected _content: UpdateBuffer; + protected _content: UpdateBufferBase; constructor(entry: FileEntry) { this._original = Buffer.from(entry.content); - this._content = new UpdateBuffer(entry.content); + this._content = UpdateBufferBase.create(entry.content); this._path = entry.path; } diff --git a/packages/angular_devkit/schematics/src/tree/recorder_spec.ts b/packages/angular_devkit/schematics/src/tree/recorder_spec.ts index 1b3140c74ebc..465b0c1a8394 100644 --- a/packages/angular_devkit/schematics/src/tree/recorder_spec.ts +++ b/packages/angular_devkit/schematics/src/tree/recorder_spec.ts @@ -7,6 +7,7 @@ */ import { normalize } from '@angular-devkit/core'; +import { UpdateBuffer2, UpdateBufferBase } from '../utility/update-buffer'; import { SimpleFileEntry } from './entry'; import { UpdateRecorderBase, UpdateRecorderBom } from './recorder'; @@ -31,6 +32,23 @@ describe('UpdateRecorderBase', () => { expect(result.toString()).toBe('Hello beautiful World'); }); + it('works with multiple adjacent inserts', () => { + const buffer = Buffer.from('Hello beautiful World'); + const entry = new SimpleFileEntry(normalize('/some/path'), buffer); + + // TODO: Remove once UpdateBufferBase.create defaults to UpdateBuffer2 + spyOn(UpdateBufferBase, 'create').and.callFake( + (originalContent) => new UpdateBuffer2(originalContent), + ); + + const recorder = new UpdateRecorderBase(entry); + recorder.remove(6, 9); + recorder.insertRight(6, 'amazing'); + recorder.insertRight(15, ' and fantastic'); + const result = recorder.apply(buffer); + expect(result.toString()).toBe('Hello amazing and fantastic World'); + }); + it('can create the proper recorder', () => { const e = new SimpleFileEntry(normalize('/some/path'), Buffer.from('hello')); expect(UpdateRecorderBase.createFromFileEntry(e) instanceof UpdateRecorderBase).toBe(true); diff --git a/packages/angular_devkit/schematics/src/utility/environment-options.ts b/packages/angular_devkit/schematics/src/utility/environment-options.ts new file mode 100644 index 000000000000..cc6042ca8b72 --- /dev/null +++ b/packages/angular_devkit/schematics/src/utility/environment-options.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +function isEnabled(variable: string): boolean { + return variable === '1' || variable.toLowerCase() === 'true'; +} + +function isPresent(variable: string | undefined): variable is string { + return typeof variable === 'string' && variable !== ''; +} + +// Use UpdateBuffer2, which uses magic-string internally. +// TODO: Switch this for the next major release to use UpdateBuffer2 by default. +const updateBufferV2 = process.env['NG_UPDATE_BUFFER_V2']; +export const updateBufferV2Enabled = isPresent(updateBufferV2) && isEnabled(updateBufferV2); diff --git a/packages/angular_devkit/schematics/src/utility/update-buffer.ts b/packages/angular_devkit/schematics/src/utility/update-buffer.ts index c2d9cd9845ad..67ceec7f5be2 100644 --- a/packages/angular_devkit/schematics/src/utility/update-buffer.ts +++ b/packages/angular_devkit/schematics/src/utility/update-buffer.ts @@ -7,6 +7,8 @@ */ import { BaseException } from '@angular-devkit/core'; +import MagicString from 'magic-string'; +import { updateBufferV2Enabled } from './environment-options'; import { LinkedList } from './linked-list'; export class IndexOutOfBoundException extends BaseException { @@ -14,6 +16,7 @@ export class IndexOutOfBoundException extends BaseException { super(`Index ${index} outside of range [${min}, ${max}].`); } } +/** @deprecated Since v13.0 */ export class ContentCannotBeRemovedException extends BaseException { constructor() { super(`User tried to remove content that was marked essential.`); @@ -26,6 +29,7 @@ export class ContentCannotBeRemovedException extends BaseException { * it means the content itself was deleted. * * @see UpdateBuffer + * @deprecated Since v13.0 */ export class Chunk { private _content: Buffer | null; @@ -176,6 +180,37 @@ export class Chunk { } } +/** + * Base class for an update buffer implementation that allows buffers to be inserted to the _right + * or _left, or deleted, while keeping indices to the original buffer. + */ +export abstract class UpdateBufferBase { + constructor(protected _originalContent: Buffer) {} + abstract get length(): number; + abstract get original(): Buffer; + abstract toString(encoding?: string): string; + abstract generate(): Buffer; + abstract insertLeft(index: number, content: Buffer, assert?: boolean): void; + abstract insertRight(index: number, content: Buffer, assert?: boolean): void; + abstract remove(index: number, length: number): void; + + /** + * Creates an UpdateBufferBase instance. Depending on the NG_UPDATE_BUFFER_V2 + * environment variable, will either create an UpdateBuffer or an UpdateBuffer2 + * instance. + * + * See: https://github.com/angular/angular-cli/issues/21110 + * + * @param originalContent The original content of the update buffer instance. + * @returns An UpdateBufferBase instance. + */ + static create(originalContent: Buffer): UpdateBufferBase { + return updateBufferV2Enabled + ? new UpdateBuffer2(originalContent) + : new UpdateBuffer(originalContent); + } +} + /** * An utility class that allows buffers to be inserted to the _right or _left, or deleted, while * keeping indices to the original buffer. @@ -185,12 +220,15 @@ export class Chunk { * * Since the Node Buffer structure is non-destructive when slicing, we try to use slicing to create * new chunks, and always keep chunks pointing to the original content. + * + * @deprecated Since v13.0 */ -export class UpdateBuffer { +export class UpdateBuffer extends UpdateBufferBase { protected _linkedList: LinkedList; - constructor(protected _originalContent: Buffer) { - this._linkedList = new LinkedList(new Chunk(0, _originalContent.length, _originalContent)); + constructor(originalContent: Buffer) { + super(originalContent); + this._linkedList = new LinkedList(new Chunk(0, originalContent.length, originalContent)); } protected _assertIndex(index: number) { @@ -274,3 +312,47 @@ export class UpdateBuffer { } } } + +/** + * An utility class that allows buffers to be inserted to the _right or _left, or deleted, while + * keeping indices to the original buffer. + */ +export class UpdateBuffer2 extends UpdateBufferBase { + protected _mutatableContent: MagicString = new MagicString(this._originalContent.toString()); + + protected _assertIndex(index: number) { + if (index < 0 || index > this._originalContent.length) { + throw new IndexOutOfBoundException(index, 0, this._originalContent.length); + } + } + + get length(): number { + return this._mutatableContent.length(); + } + get original(): Buffer { + return this._originalContent; + } + + toString(): string { + return this._mutatableContent.toString(); + } + + generate(): Buffer { + return Buffer.from(this.toString()); + } + + insertLeft(index: number, content: Buffer): void { + this._assertIndex(index); + this._mutatableContent.appendLeft(index, content.toString()); + } + + insertRight(index: number, content: Buffer): void { + this._assertIndex(index); + this._mutatableContent.appendRight(index, content.toString()); + } + + remove(index: number, length: number) { + this._assertIndex(index); + this._mutatableContent.remove(index, index + length); + } +} diff --git a/packages/angular_devkit/schematics/src/utility/update-buffer_spec.ts b/packages/angular_devkit/schematics/src/utility/update-buffer_spec.ts index 9b8c7a343919..93d46dde0fc3 100644 --- a/packages/angular_devkit/schematics/src/utility/update-buffer_spec.ts +++ b/packages/angular_devkit/schematics/src/utility/update-buffer_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import { UpdateBuffer } from './update-buffer'; +import { UpdateBuffer, UpdateBuffer2 } from './update-buffer'; describe('UpdateBuffer', () => { describe('inserts', () => { @@ -198,3 +198,180 @@ describe('UpdateBuffer', () => { }); }); }); + +describe('UpdateBuffer2', () => { + describe('inserts', () => { + it('works', () => { + const mb = new UpdateBuffer2(Buffer.from('Hello World')); + + mb.insertRight(6, Buffer.from('Beautiful ')); + expect(mb.toString()).toBe('Hello Beautiful World'); + + mb.insertRight(6, Buffer.from('Great ')); + expect(mb.toString()).toBe('Hello Beautiful Great World'); + + mb.insertRight(0, Buffer.from('1 ')); + expect(mb.toString()).toBe('1 Hello Beautiful Great World'); + + mb.insertRight(5, Buffer.from('2 ')); + expect(mb.toString()).toBe('1 Hello2 Beautiful Great World'); + + mb.insertRight(8, Buffer.from('3 ')); + expect(mb.toString()).toBe('1 Hello2 Beautiful Great Wo3 rld'); + + mb.insertRight(0, Buffer.from('4 ')); + expect(mb.toString()).toBe('1 4 Hello2 Beautiful Great Wo3 rld'); + + mb.insertRight(8, Buffer.from('5 ')); + expect(mb.toString()).toBe('1 4 Hello2 Beautiful Great Wo3 5 rld'); + + mb.insertRight(1, Buffer.from('a ')); + expect(mb.toString()).toBe('1 4 Ha ello2 Beautiful Great Wo3 5 rld'); + + mb.insertRight(2, Buffer.from('b ')); + expect(mb.toString()).toBe('1 4 Ha eb llo2 Beautiful Great Wo3 5 rld'); + + mb.insertRight(7, Buffer.from('c ')); + expect(mb.toString()).toBe('1 4 Ha eb llo2 Beautiful Great Wc o3 5 rld'); + + mb.insertRight(11, Buffer.from('d ')); + expect(mb.toString()).toBe('1 4 Ha eb llo2 Beautiful Great Wc o3 5 rldd '); + }); + + it('works _left and _right', () => { + const mb = new UpdateBuffer2(Buffer.from('Hello World')); + + mb.insertRight(6, Buffer.from('Beautiful ')); + expect(mb.toString()).toBe('Hello Beautiful World'); + + mb.insertLeft(6, Buffer.from('Great ')); + expect(mb.toString()).toBe('Hello Great Beautiful World'); + + mb.insertLeft(6, Buffer.from('Awesome ')); + expect(mb.toString()).toBe('Hello Great Awesome Beautiful World'); + }); + + it('works with special characters', () => { + const mb = new UpdateBuffer2(Buffer.from('Ülaut')); + + mb.insertLeft(1, Buffer.from('m')); + expect(mb.toString()).toBe('Ümlaut'); + + mb.insertLeft(0, Buffer.from('Hello ')); + expect(mb.toString()).toBe('Hello Ümlaut'); + }); + }); + + describe('delete', () => { + it('works for non-overlapping ranges', () => { + // 111111111122222222223333333333444444 + // 0123456789012345678901234567890123456789012345 + const mb = new UpdateBuffer2(Buffer.from('1 4 Ha eb llo2 Beautiful Great Wc o3 5 rldd ')); + + mb.remove(43, 2); + expect(mb.toString()).toBe('1 4 Ha eb llo2 Beautiful Great Wc o3 5 rld'); + mb.remove(33, 2); + expect(mb.toString()).toBe('1 4 Ha eb llo2 Beautiful Great Wo3 5 rld'); + mb.remove(8, 2); + expect(mb.toString()).toBe('1 4 Ha ello2 Beautiful Great Wo3 5 rld'); + mb.remove(5, 2); + expect(mb.toString()).toBe('1 4 Hello2 Beautiful Great Wo3 5 rld'); + mb.remove(38, 2); + expect(mb.toString()).toBe('1 4 Hello2 Beautiful Great Wo3 rld'); + mb.remove(2, 2); + expect(mb.toString()).toBe('1 Hello2 Beautiful Great Wo3 rld'); + mb.remove(36, 2); + expect(mb.toString()).toBe('1 Hello2 Beautiful Great World'); + mb.remove(13, 2); + expect(mb.toString()).toBe('1 Hello Beautiful Great World'); + mb.remove(0, 2); + expect(mb.toString()).toBe('Hello Beautiful Great World'); + mb.remove(26, 6); + expect(mb.toString()).toBe('Hello Beautiful World'); + mb.remove(16, 10); + expect(mb.toString()).toBe('Hello World'); + }); + + it('handles overlapping ranges', () => { + // 0123456789012 + const mb = new UpdateBuffer2(Buffer.from('ABCDEFGHIJKLM')); + + // Overlapping. + mb.remove(2, 5); + expect(mb.toString()).toBe('ABHIJKLM'); + mb.remove(3, 2); + expect(mb.toString()).toBe('ABHIJKLM'); + mb.remove(3, 6); + expect(mb.toString()).toBe('ABJKLM'); + mb.remove(3, 6); + expect(mb.toString()).toBe('ABJKLM'); + mb.remove(10, 1); + expect(mb.toString()).toBe('ABJLM'); + mb.remove(1, 11); + expect(mb.toString()).toBe('AM'); + }); + }); + + describe('inserts and deletes', () => { + it('works for non-overlapping indices', () => { + // 1 + // 01234567890 + const mb = new UpdateBuffer2(Buffer.from('01234567890')); + + mb.insertRight(6, Buffer.from('A')); + expect(mb.toString()).toBe('012345A67890'); + mb.insertRight(2, Buffer.from('B')); + expect(mb.toString()).toBe('01B2345A67890'); + + mb.remove(3, 4); + expect(mb.toString()).toBe('01B27890'); + mb.insertRight(4, Buffer.from('C')); + expect(mb.toString()).toBe('01B2C7890'); + + mb.remove(2, 6); + expect(mb.toString()).toBe('01890'); + }); + + it('works for _left/_right inserts', () => { + // 0123456789 + const mb = new UpdateBuffer2(Buffer.from('0123456789')); + + mb.insertLeft(5, Buffer.from('A')); + expect(mb.toString()).toBe('01234A56789'); + mb.insertRight(5, Buffer.from('B')); + expect(mb.toString()).toBe('01234AB56789'); + mb.insertRight(10, Buffer.from('C')); + expect(mb.toString()).toBe('01234AB56789C'); + mb.remove(5, 5); + expect(mb.toString()).toBe('01234AC'); + mb.remove(0, 5); + expect(mb.toString()).toBe('C'); + }); + + it('works for content at start/end of buffer', () => { + const buffer = new UpdateBuffer2(Buffer.from('012345')); + buffer.insertLeft(0, Buffer.from('ABC')); + buffer.insertRight(6, Buffer.from('DEF')); + buffer.remove(0, 6); + expect(buffer.toString()).toBe('ABCDEF'); + }); + }); + + describe('generate', () => { + it('works', () => { + // 0123456789 + const mb = new UpdateBuffer2(Buffer.from('0123456789')); + + mb.insertLeft(5, Buffer.from('A')); + expect(mb.toString()).toBe('01234A56789'); + mb.remove(5, 5); + expect(mb.toString()).toBe('01234A'); + mb.remove(0, 5); + expect(mb.toString()).toBe(''); + + const buffer = mb.generate(); + expect(buffer.toString()).toBe(''); + expect(buffer.length).toBe(0); + }); + }); +});