Skip to content

Commit a0ac4b0

Browse files
clydinKeen Yee Liau
authored and
Keen Yee Liau
committed
feat(@angular-devkit/schematics): support executing a schematic rule on a subtree
1 parent 5c73ee3 commit a0ac4b0

File tree

6 files changed

+325
-1
lines changed

6 files changed

+325
-1
lines changed

etc/api/angular_devkit/schematics/src/_golden-api.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ export interface EngineHost<CollectionMetadataT extends object, SchematicMetadat
218218

219219
export interface ExecutionOptions {
220220
interactive: boolean;
221+
scope: string;
221222
}
222223

223224
export declare function externalSchematic<OptionT extends object>(collectionName: string, schematicName: string, options: OptionT, executionOptions?: Partial<ExecutionOptions>): Rule;

packages/angular_devkit/schematics/src/engine/interface.ts

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface TaskInfo {
4242
}
4343

4444
export interface ExecutionOptions {
45+
scope: string;
4546
interactive: boolean;
4647
}
4748

packages/angular_devkit/schematics/src/engine/schematic.ts

+23-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Observable, of as observableOf } from 'rxjs';
1010
import { concatMap, first, map } from 'rxjs/operators';
1111
import { callRule } from '../rules/call';
1212
import { Tree } from '../tree/interface';
13+
import { ScopedTree } from '../tree/scoped';
1314
import {
1415
Collection,
1516
Engine,
@@ -58,7 +59,28 @@ export class SchematicImpl<CollectionT extends object, SchematicT extends object
5859
map(o => [tree, o]),
5960
)),
6061
concatMap(([tree, transformedOptions]: [Tree, OptionT]) => {
61-
return callRule(this._factory(transformedOptions), observableOf(tree), context);
62+
let input: Tree;
63+
let scoped = false;
64+
if (executionOptions && executionOptions.scope) {
65+
scoped = true;
66+
input = new ScopedTree(tree, executionOptions.scope);
67+
} else {
68+
input = tree;
69+
}
70+
71+
return callRule(this._factory(transformedOptions), observableOf(input), context).pipe(
72+
map(output => {
73+
if (output === input) {
74+
return tree;
75+
} else if (scoped) {
76+
tree.merge(output);
77+
78+
return tree;
79+
} else {
80+
return output;
81+
}
82+
}),
83+
);
6284
}),
6385
);
6486
}

packages/angular_devkit/schematics/src/engine/schematic_spec.ts

+20
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,24 @@ describe('Schematic', () => {
147147
.then(done, done.fail);
148148
});
149149

150+
it('can be called with a scope', done => {
151+
const desc: SchematicDescription<CollectionT, SchematicT> = {
152+
collection,
153+
name: 'test',
154+
description: '',
155+
path: '/a/b/c',
156+
factory: () => (tree: Tree) => {
157+
tree.create('a/b/c', 'some content');
158+
},
159+
};
160+
161+
const schematic = new SchematicImpl(desc, desc.factory, null !, engine);
162+
schematic.call({}, observableOf(empty()), {}, { scope: 'base' })
163+
.toPromise()
164+
.then(x => {
165+
expect(files(x)).toEqual(['/base/a/b/c']);
166+
})
167+
.then(done, done.fail);
168+
});
169+
150170
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {
9+
NormalizedRoot,
10+
Path,
11+
PathFragment,
12+
join,
13+
normalize,
14+
relative,
15+
} from '@angular-devkit/core';
16+
import { Action } from './action';
17+
import {
18+
DirEntry,
19+
FileEntry,
20+
FileVisitor,
21+
MergeStrategy,
22+
Tree,
23+
TreeSymbol,
24+
UpdateRecorder,
25+
} from './interface';
26+
27+
class ScopedFileEntry implements FileEntry {
28+
constructor(private _base: FileEntry, private scope: Path) {}
29+
30+
get path(): Path {
31+
return join(NormalizedRoot, relative(this.scope, this._base.path));
32+
}
33+
34+
get content(): Buffer { return this._base.content; }
35+
}
36+
37+
class ScopedDirEntry implements DirEntry {
38+
constructor(private _base: DirEntry, readonly scope: Path) {}
39+
40+
get parent(): DirEntry | null {
41+
if (!this._base.parent || this._base.path == this.scope) {
42+
return null;
43+
}
44+
45+
return new ScopedDirEntry(this._base.parent, this.scope);
46+
}
47+
48+
get path(): Path {
49+
return join(NormalizedRoot, relative(this.scope, this._base.path));
50+
}
51+
52+
get subdirs(): PathFragment[] {
53+
return this._base.subdirs;
54+
}
55+
get subfiles(): PathFragment[] {
56+
return this._base.subfiles;
57+
}
58+
59+
dir(name: PathFragment): DirEntry {
60+
const entry = this._base.dir(name);
61+
62+
return entry && new ScopedDirEntry(entry, this.scope);
63+
}
64+
65+
file(name: PathFragment): FileEntry | null {
66+
const entry = this._base.file(name);
67+
68+
return entry && new ScopedFileEntry(entry, this.scope);
69+
}
70+
71+
visit(visitor: FileVisitor): void {
72+
return this._base.visit((path, entry) => {
73+
visitor(
74+
join(NormalizedRoot, relative(this.scope, path)),
75+
entry && new ScopedFileEntry(entry, this.scope),
76+
);
77+
});
78+
}
79+
}
80+
81+
export class ScopedTree implements Tree {
82+
readonly _root: ScopedDirEntry;
83+
84+
constructor(private _base: Tree, scope: string) {
85+
const normalizedScope = normalize('/' + scope);
86+
this._root = new ScopedDirEntry(this._base.getDir(normalizedScope), normalizedScope);
87+
}
88+
89+
get root(): DirEntry { return this._root; }
90+
91+
branch(): Tree { return new ScopedTree(this._base.branch(), this._root.scope); }
92+
merge(other: Tree, strategy?: MergeStrategy): void { this._base.merge(other, strategy); }
93+
94+
// Readonly.
95+
read(path: string): Buffer | null { return this._base.read(this._fullPath(path)); }
96+
exists(path: string): boolean { return this._base.exists(this._fullPath(path)); }
97+
get(path: string): FileEntry | null {
98+
const entry = this._base.get(this._fullPath(path));
99+
100+
return entry && new ScopedFileEntry(entry, this._root.scope);
101+
}
102+
getDir(path: string): DirEntry {
103+
const entry = this._base.getDir(this._fullPath(path));
104+
105+
return entry && new ScopedDirEntry(entry, this._root.scope);
106+
}
107+
visit(visitor: FileVisitor): void { return this._root.visit(visitor); }
108+
109+
// Change content of host files.
110+
overwrite(path: string, content: Buffer | string): void {
111+
return this._base.overwrite(this._fullPath(path), content);
112+
}
113+
beginUpdate(path: string): UpdateRecorder {
114+
return this._base.beginUpdate(this._fullPath(path));
115+
}
116+
commitUpdate(record: UpdateRecorder): void { return this._base.commitUpdate(record); }
117+
118+
// Structural methods.
119+
create(path: string, content: Buffer | string): void {
120+
return this._base.create(this._fullPath(path), content);
121+
}
122+
delete(path: string): void { return this._base.delete(this._fullPath(path)); }
123+
rename(from: string, to: string): void {
124+
return this._base.rename(this._fullPath(from), this._fullPath(to));
125+
}
126+
127+
apply(action: Action, strategy?: MergeStrategy): void {
128+
return this._base.apply(action, strategy);
129+
}
130+
get actions(): Action[] { return this._base.actions; }
131+
132+
[TreeSymbol]() {
133+
return this;
134+
}
135+
136+
private _fullPath(path: string) {
137+
return join(this._root.scope, normalize('/' + path));
138+
}
139+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import { UnitTestTree } from '../../testing';
9+
import { HostTree } from './host-tree';
10+
import { ScopedTree } from './scoped';
11+
12+
13+
describe('ScopedTree', () => {
14+
let base: HostTree;
15+
let scoped: ScopedTree;
16+
17+
beforeEach(() => {
18+
base = new HostTree();
19+
base.create('/file-0-1', '0-1');
20+
base.create('/file-0-2', '0-2');
21+
base.create('/file-0-3', '0-3');
22+
base.create('/level-1/file-1-1', '1-1');
23+
base.create('/level-1/file-1-2', '1-2');
24+
base.create('/level-1/file-1-3', '1-3');
25+
base.create('/level-1/level-2/file-2-1', '2-1');
26+
base.create('/level-1/level-2/file-2-2', '2-2');
27+
base.create('/level-1/level-2/file-2-3', '2-3');
28+
29+
scoped = new ScopedTree(base, 'level-1');
30+
});
31+
32+
it('supports exists', () => {
33+
expect(scoped.exists('/file-1-1')).toBeTruthy();
34+
expect(scoped.exists('file-1-1')).toBeTruthy();
35+
expect(scoped.exists('/level-2/file-2-1')).toBeTruthy();
36+
expect(scoped.exists('level-2/file-2-1')).toBeTruthy();
37+
38+
expect(scoped.exists('/file-1-4')).toBeFalsy();
39+
expect(scoped.exists('file-1-4')).toBeFalsy();
40+
41+
expect(scoped.exists('/file-0-1')).toBeFalsy();
42+
expect(scoped.exists('file-0-1')).toBeFalsy();
43+
expect(scoped.exists('/level-1/file-1-1')).toBeFalsy();
44+
expect(scoped.exists('level-1/file-1-1')).toBeFalsy();
45+
});
46+
47+
it('supports read', () => {
48+
expect(scoped.read('/file-1-2')).not.toBeNull();
49+
expect(scoped.read('file-1-2')).not.toBeNull();
50+
51+
const test = new UnitTestTree(scoped);
52+
expect(test.readContent('/file-1-2')).toBe('1-2');
53+
expect(test.readContent('file-1-2')).toBe('1-2');
54+
55+
expect(scoped.read('/file-0-2')).toBeNull();
56+
expect(scoped.read('file-0-2')).toBeNull();
57+
});
58+
59+
it('supports create', () => {
60+
expect(() => scoped.create('/file-1-4', '1-4')).not.toThrow();
61+
62+
const test = new UnitTestTree(scoped);
63+
expect(test.readContent('/file-1-4')).toBe('1-4');
64+
expect(test.readContent('file-1-4')).toBe('1-4');
65+
66+
expect(base.exists('/level-1/file-1-4')).toBeTruthy();
67+
});
68+
69+
it('supports delete', () => {
70+
expect(() => scoped.delete('/file-0-3')).toThrow();
71+
72+
expect(() => scoped.delete('/file-1-3')).not.toThrow();
73+
expect(scoped.exists('/file-1-3')).toBeFalsy();
74+
75+
expect(base.exists('/level-1/file-1-3')).toBeFalsy();
76+
});
77+
78+
it('supports overwrite', () => {
79+
expect(() => scoped.overwrite('/file-1-1', '1-1*')).not.toThrow();
80+
expect(() => scoped.overwrite('/file-1-4', '1-4*')).toThrow();
81+
82+
const test = new UnitTestTree(scoped);
83+
expect(test.readContent('/file-1-1')).toBe('1-1*');
84+
expect(test.readContent('file-1-1')).toBe('1-1*');
85+
});
86+
87+
it('supports rename', () => {
88+
expect(() => scoped.rename('/file-1-1', '/file-1-1-new')).not.toThrow();
89+
expect(() => scoped.rename('/file-1-4', '/file-1-4-new')).toThrow();
90+
91+
const test = new UnitTestTree(scoped);
92+
expect(test.readContent('/file-1-1-new')).toBe('1-1');
93+
expect(test.readContent('file-1-1-new')).toBe('1-1');
94+
});
95+
96+
it('supports get', () => {
97+
expect(scoped.get('/file-1-1')).not.toBeNull();
98+
99+
const file = scoped.get('file-1-1');
100+
expect(file && file.path as string).toBe('/file-1-1');
101+
102+
expect(scoped.get('/file-0-1')).toBeNull();
103+
expect(scoped.get('file-0-1')).toBeNull();
104+
});
105+
106+
it('supports getDir', () => {
107+
expect(scoped.getDir('/level-2')).not.toBeNull();
108+
109+
const dir = scoped.getDir('level-2');
110+
expect(dir.path as string).toBe('/level-2');
111+
expect(dir.parent).not.toBeNull();
112+
const files: string[] = [];
113+
dir.visit(path => files.push(path));
114+
files.sort();
115+
expect(files).toEqual([
116+
'/level-2/file-2-1',
117+
'/level-2/file-2-2',
118+
'/level-2/file-2-3',
119+
]);
120+
});
121+
122+
it('supports visit', () => {
123+
const files: string[] = [];
124+
scoped.visit(path => files.push(path));
125+
files.sort();
126+
expect(files).toEqual([
127+
'/file-1-1',
128+
'/file-1-2',
129+
'/file-1-3',
130+
'/level-2/file-2-1',
131+
'/level-2/file-2-2',
132+
'/level-2/file-2-3',
133+
]);
134+
});
135+
136+
it('supports root', () => {
137+
expect(scoped.root).not.toBeNull();
138+
expect(scoped.root.path as string).toBe('/');
139+
expect(scoped.root.parent).toBeNull();
140+
});
141+
});

0 commit comments

Comments
 (0)