feat(@angular-devkit/schematics): support executing a schematic rule on a subtree

This commit is contained in:
Charles Lyding 2018-12-21 16:05:34 -05:00 committed by Keen Yee Liau
parent 5c73ee3537
commit a0ac4b0e3d
6 changed files with 325 additions and 1 deletions

View File

@ -218,6 +218,7 @@ export interface EngineHost<CollectionMetadataT extends object, SchematicMetadat
export interface ExecutionOptions {
interactive: boolean;
scope: string;
}
export declare function externalSchematic<OptionT extends object>(collectionName: string, schematicName: string, options: OptionT, executionOptions?: Partial<ExecutionOptions>): Rule;

View File

@ -42,6 +42,7 @@ export interface TaskInfo {
}
export interface ExecutionOptions {
scope: string;
interactive: boolean;
}

View File

@ -10,6 +10,7 @@ import { Observable, of as observableOf } from 'rxjs';
import { concatMap, first, map } from 'rxjs/operators';
import { callRule } from '../rules/call';
import { Tree } from '../tree/interface';
import { ScopedTree } from '../tree/scoped';
import {
Collection,
Engine,
@ -58,7 +59,28 @@ export class SchematicImpl<CollectionT extends object, SchematicT extends object
map(o => [tree, o]),
)),
concatMap(([tree, transformedOptions]: [Tree, OptionT]) => {
return callRule(this._factory(transformedOptions), observableOf(tree), context);
let input: Tree;
let scoped = false;
if (executionOptions && executionOptions.scope) {
scoped = true;
input = new ScopedTree(tree, executionOptions.scope);
} else {
input = tree;
}
return callRule(this._factory(transformedOptions), observableOf(input), context).pipe(
map(output => {
if (output === input) {
return tree;
} else if (scoped) {
tree.merge(output);
return tree;
} else {
return output;
}
}),
);
}),
);
}

View File

@ -147,4 +147,24 @@ describe('Schematic', () => {
.then(done, done.fail);
});
it('can be called with a scope', done => {
const desc: SchematicDescription<CollectionT, SchematicT> = {
collection,
name: 'test',
description: '',
path: '/a/b/c',
factory: () => (tree: Tree) => {
tree.create('a/b/c', 'some content');
},
};
const schematic = new SchematicImpl(desc, desc.factory, null !, engine);
schematic.call({}, observableOf(empty()), {}, { scope: 'base' })
.toPromise()
.then(x => {
expect(files(x)).toEqual(['/base/a/b/c']);
})
.then(done, done.fail);
});
});

View File

@ -0,0 +1,139 @@
/**
* @license
* Copyright Google Inc. 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
*/
import {
NormalizedRoot,
Path,
PathFragment,
join,
normalize,
relative,
} from '@angular-devkit/core';
import { Action } from './action';
import {
DirEntry,
FileEntry,
FileVisitor,
MergeStrategy,
Tree,
TreeSymbol,
UpdateRecorder,
} from './interface';
class ScopedFileEntry implements FileEntry {
constructor(private _base: FileEntry, private scope: Path) {}
get path(): Path {
return join(NormalizedRoot, relative(this.scope, this._base.path));
}
get content(): Buffer { return this._base.content; }
}
class ScopedDirEntry implements DirEntry {
constructor(private _base: DirEntry, readonly scope: Path) {}
get parent(): DirEntry | null {
if (!this._base.parent || this._base.path == this.scope) {
return null;
}
return new ScopedDirEntry(this._base.parent, this.scope);
}
get path(): Path {
return join(NormalizedRoot, relative(this.scope, this._base.path));
}
get subdirs(): PathFragment[] {
return this._base.subdirs;
}
get subfiles(): PathFragment[] {
return this._base.subfiles;
}
dir(name: PathFragment): DirEntry {
const entry = this._base.dir(name);
return entry && new ScopedDirEntry(entry, this.scope);
}
file(name: PathFragment): FileEntry | null {
const entry = this._base.file(name);
return entry && new ScopedFileEntry(entry, this.scope);
}
visit(visitor: FileVisitor): void {
return this._base.visit((path, entry) => {
visitor(
join(NormalizedRoot, relative(this.scope, path)),
entry && new ScopedFileEntry(entry, this.scope),
);
});
}
}
export class ScopedTree implements Tree {
readonly _root: ScopedDirEntry;
constructor(private _base: Tree, scope: string) {
const normalizedScope = normalize('/' + scope);
this._root = new ScopedDirEntry(this._base.getDir(normalizedScope), normalizedScope);
}
get root(): DirEntry { return this._root; }
branch(): Tree { return new ScopedTree(this._base.branch(), this._root.scope); }
merge(other: Tree, strategy?: MergeStrategy): void { this._base.merge(other, strategy); }
// Readonly.
read(path: string): Buffer | null { return this._base.read(this._fullPath(path)); }
exists(path: string): boolean { return this._base.exists(this._fullPath(path)); }
get(path: string): FileEntry | null {
const entry = this._base.get(this._fullPath(path));
return entry && new ScopedFileEntry(entry, this._root.scope);
}
getDir(path: string): DirEntry {
const entry = this._base.getDir(this._fullPath(path));
return entry && new ScopedDirEntry(entry, this._root.scope);
}
visit(visitor: FileVisitor): void { return this._root.visit(visitor); }
// Change content of host files.
overwrite(path: string, content: Buffer | string): void {
return this._base.overwrite(this._fullPath(path), content);
}
beginUpdate(path: string): UpdateRecorder {
return this._base.beginUpdate(this._fullPath(path));
}
commitUpdate(record: UpdateRecorder): void { return this._base.commitUpdate(record); }
// Structural methods.
create(path: string, content: Buffer | string): void {
return this._base.create(this._fullPath(path), content);
}
delete(path: string): void { return this._base.delete(this._fullPath(path)); }
rename(from: string, to: string): void {
return this._base.rename(this._fullPath(from), this._fullPath(to));
}
apply(action: Action, strategy?: MergeStrategy): void {
return this._base.apply(action, strategy);
}
get actions(): Action[] { return this._base.actions; }
[TreeSymbol]() {
return this;
}
private _fullPath(path: string) {
return join(this._root.scope, normalize('/' + path));
}
}

View File

@ -0,0 +1,141 @@
/**
* @license
* Copyright Google Inc. 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
*/
import { UnitTestTree } from '../../testing';
import { HostTree } from './host-tree';
import { ScopedTree } from './scoped';
describe('ScopedTree', () => {
let base: HostTree;
let scoped: ScopedTree;
beforeEach(() => {
base = new HostTree();
base.create('/file-0-1', '0-1');
base.create('/file-0-2', '0-2');
base.create('/file-0-3', '0-3');
base.create('/level-1/file-1-1', '1-1');
base.create('/level-1/file-1-2', '1-2');
base.create('/level-1/file-1-3', '1-3');
base.create('/level-1/level-2/file-2-1', '2-1');
base.create('/level-1/level-2/file-2-2', '2-2');
base.create('/level-1/level-2/file-2-3', '2-3');
scoped = new ScopedTree(base, 'level-1');
});
it('supports exists', () => {
expect(scoped.exists('/file-1-1')).toBeTruthy();
expect(scoped.exists('file-1-1')).toBeTruthy();
expect(scoped.exists('/level-2/file-2-1')).toBeTruthy();
expect(scoped.exists('level-2/file-2-1')).toBeTruthy();
expect(scoped.exists('/file-1-4')).toBeFalsy();
expect(scoped.exists('file-1-4')).toBeFalsy();
expect(scoped.exists('/file-0-1')).toBeFalsy();
expect(scoped.exists('file-0-1')).toBeFalsy();
expect(scoped.exists('/level-1/file-1-1')).toBeFalsy();
expect(scoped.exists('level-1/file-1-1')).toBeFalsy();
});
it('supports read', () => {
expect(scoped.read('/file-1-2')).not.toBeNull();
expect(scoped.read('file-1-2')).not.toBeNull();
const test = new UnitTestTree(scoped);
expect(test.readContent('/file-1-2')).toBe('1-2');
expect(test.readContent('file-1-2')).toBe('1-2');
expect(scoped.read('/file-0-2')).toBeNull();
expect(scoped.read('file-0-2')).toBeNull();
});
it('supports create', () => {
expect(() => scoped.create('/file-1-4', '1-4')).not.toThrow();
const test = new UnitTestTree(scoped);
expect(test.readContent('/file-1-4')).toBe('1-4');
expect(test.readContent('file-1-4')).toBe('1-4');
expect(base.exists('/level-1/file-1-4')).toBeTruthy();
});
it('supports delete', () => {
expect(() => scoped.delete('/file-0-3')).toThrow();
expect(() => scoped.delete('/file-1-3')).not.toThrow();
expect(scoped.exists('/file-1-3')).toBeFalsy();
expect(base.exists('/level-1/file-1-3')).toBeFalsy();
});
it('supports overwrite', () => {
expect(() => scoped.overwrite('/file-1-1', '1-1*')).not.toThrow();
expect(() => scoped.overwrite('/file-1-4', '1-4*')).toThrow();
const test = new UnitTestTree(scoped);
expect(test.readContent('/file-1-1')).toBe('1-1*');
expect(test.readContent('file-1-1')).toBe('1-1*');
});
it('supports rename', () => {
expect(() => scoped.rename('/file-1-1', '/file-1-1-new')).not.toThrow();
expect(() => scoped.rename('/file-1-4', '/file-1-4-new')).toThrow();
const test = new UnitTestTree(scoped);
expect(test.readContent('/file-1-1-new')).toBe('1-1');
expect(test.readContent('file-1-1-new')).toBe('1-1');
});
it('supports get', () => {
expect(scoped.get('/file-1-1')).not.toBeNull();
const file = scoped.get('file-1-1');
expect(file && file.path as string).toBe('/file-1-1');
expect(scoped.get('/file-0-1')).toBeNull();
expect(scoped.get('file-0-1')).toBeNull();
});
it('supports getDir', () => {
expect(scoped.getDir('/level-2')).not.toBeNull();
const dir = scoped.getDir('level-2');
expect(dir.path as string).toBe('/level-2');
expect(dir.parent).not.toBeNull();
const files: string[] = [];
dir.visit(path => files.push(path));
files.sort();
expect(files).toEqual([
'/level-2/file-2-1',
'/level-2/file-2-2',
'/level-2/file-2-3',
]);
});
it('supports visit', () => {
const files: string[] = [];
scoped.visit(path => files.push(path));
files.sort();
expect(files).toEqual([
'/file-1-1',
'/file-1-2',
'/file-1-3',
'/level-2/file-2-1',
'/level-2/file-2-2',
'/level-2/file-2-3',
]);
});
it('supports root', () => {
expect(scoped.root).not.toBeNull();
expect(scoped.root.path as string).toBe('/');
expect(scoped.root.parent).toBeNull();
});
});