implement ngrx generation

This commit is contained in:
vsavkin 2017-08-11 14:05:27 -04:00
parent b97520fd9c
commit cdb115ddb8
18 changed files with 1660 additions and 98 deletions

4
.gitignore vendored
View File

@ -1,4 +1,6 @@
node_modules
.idea
dist
build
build
.DS_Store
tmp

View File

@ -1,12 +1,84 @@
import {cleanup, runCLI, runSchematic} from './utils';
import {
addNgRx, checkFilesExists, cleanup, newApp, readFile, runCLI, runCommand, runSchematic,
updateFile
} from './utils';
describe('addNgRxToModule', () => {
beforeEach(cleanup);
it('should add ngrx to module', () => {
runCLI('new proj --skip-install ');
runSchematic('@nrwl/ext:addNgRxToModule', {cwd: 'proj'});
it('should add root configuration', () => {
newApp('new proj --skipInstall');
runSchematic('@nrwl/ext:addNgRxToModule --module=src/app/app.module.ts --root', {cwd: 'proj'});
checkFilesExists(
`proj/src/app/+state/app.actions.ts`,
`proj/src/app/+state/app.effects.ts`,
`proj/src/app/+state/app.effects.spec.ts`,
`proj/src/app/+state/app.init.ts`,
`proj/src/app/+state/app.interfaces.ts`,
`proj/src/app/+state/app.reducer.ts`,
`proj/src/app/+state/app.reducer.spec.ts`
);
const contents = readFile('proj/src/app/app.module.ts');
expect(contents).toContain('StoreModule.forRoot');
expect(contents).toContain('EffectsModule.forRoot');
addNgRx('proj');
runCLI('build', {cwd: 'proj'});
runCLI('test --single-run', {cwd: 'proj'});
expect(1).toEqual(2);
}, 50000);
it('should add empty root configuration', () => {
newApp('new proj2 --skipInstall');
runSchematic('@nrwl/ext:addNgRxToModule --module=src/app/app.module.ts --emptyRoot', {cwd: 'proj2'});
const contents = readFile('proj2/src/app/app.module.ts');
expect(contents).toContain('StoreModule.forRoot');
expect(contents).not.toContain('EffectsModule.forRoot');
addNgRx('proj2');
runCLI('build', {cwd: 'proj2'});
}, 50000);
it('should add feature configuration', () => {
newApp('new proj3 --skipInstall');
runSchematic('@nrwl/ext:addNgRxToModule --module=src/app/app.module.ts', {cwd: 'proj3'});
checkFilesExists(
`proj3/src/app/+state/app.actions.ts`,
`proj3/src/app/+state/app.effects.ts`,
`proj3/src/app/+state/app.effects.spec.ts`,
`proj3/src/app/+state/app.init.ts`,
`proj3/src/app/+state/app.interfaces.ts`,
`proj3/src/app/+state/app.reducer.ts`,
`proj3/src/app/+state/app.reducer.spec.ts`
);
const contents = readFile('proj3/src/app/app.module.ts');
expect(contents).toContain('StoreModule.forFeature');
expect(contents).toContain('EffectsModule.forFeature');
});
it('should generate files without importing them', () => {
newApp('new proj4 --skipInstall');
runSchematic('@nrwl/ext:addNgRxToModule --module=src/app/app.module.ts --skipImport', {cwd: 'proj4'});
checkFilesExists(
`proj4/src/app/+state/app.actions.ts`,
`proj4/src/app/+state/app.effects.ts`,
`proj4/src/app/+state/app.effects.spec.ts`,
`proj4/src/app/+state/app.init.ts`,
`proj4/src/app/+state/app.interfaces.ts`,
`proj4/src/app/+state/app.reducer.ts`,
`proj4/src/app/+state/app.reducer.spec.ts`
);
const contents = readFile('proj4/src/app/app.module.ts');
expect(contents).not.toContain('StoreModule');
expect(contents).not.toContain('EffectsModule');
});
});

View File

@ -1,32 +1,74 @@
import {execSync} from 'child_process';
import * as path from 'path';
import {readFileSync, statSync, writeFileSync} from 'fs';
export function runCLI(command: string, {cwd}: {cwd?: string} = {}): string {
cwd = cwd === undefined ? '' : cwd;
return execSync(`../node_modules/.bin/ng ${command}`, {cwd: `./tmp/${cwd}`}).toString();
export function newApp(command: string): string {
return execSync(`../node_modules/.bin/ng ${command}`, {cwd: `./tmp`}).toString();
}
export function runSchematic(command: string, {cwd}: {cwd?: string} = {}): string {
export function runCLI(command: string, {cwd}: {cwd: string}): string {
cwd = cwd === undefined ? '' : cwd;
return execSync(`../../node_modules/.bin/ng ${command}`, {cwd: `./tmp/${cwd}`}).toString();
}
export function runSchematic(command: string, {cwd}: {cwd: string}): string {
cwd = cwd === undefined ? '' : cwd;
return execSync(`../../node_modules/.bin/schematics ${command}`, {cwd: `./tmp/${cwd}`}).toString();
}
export function runCommand(command: string, {cwd}: {cwd: string}): string {
cwd = cwd === undefined ? '' : cwd;
return execSync(command, {cwd: `./tmp/${cwd}`}).toString();
}
// export function updateFile(f: string, content: string): void {
// writeFileSync(path.join(files.getCwd(), 'tmp', f), content);
// }
export function updateFile(f: string, content: string): void {
writeFileSync(path.join(getCwd(), 'tmp', f), content);
}
// export function checkFilesExists(...expectedFiles: string[]) {
// expectedFiles.forEach(f => {
// const ff = f.startsWith('/') ? f : path.join(files.getCwd(), 'tmp', f);
// if (! files.exists(ff)) {
// throw new Error(`File '${ff}' does not exist`);
// }
// });
// }
export function checkFilesExists(...expectedFiles: string[]) {
expectedFiles.forEach(f => {
const ff = f.startsWith('/') ? f : path.join(getCwd(), 'tmp', f);
if (! exists(ff)) {
throw new Error(`File '${ff}' does not exist`);
}
});
}
// export function readFile(f: string) {
// const ff = f.startsWith('/') ? f : path.join(files.getCwd(), 'tmp', f);
// return readFileSync(ff).toString();
// }
export function readFile(f: string) {
const ff = f.startsWith('/') ? f : path.join(getCwd(), 'tmp', f);
return readFileSync(ff).toString();
}
export function cleanup() {
execSync('rm -rf tmp && mkdir tmp');
}
export function getCwd(): string {
return process.cwd();
}
export function directoryExists(filePath: string): boolean {
try {
return statSync(filePath).isDirectory();
} catch (err) {
return false;
}
}
export function fileExists(filePath: string): boolean {
try {
return statSync(filePath).isFile();
} catch (err) {
return false;
}
}
export function exists(filePath: string): boolean {
return directoryExists(filePath) || fileExists(filePath);
}
export function addNgRx(path: string): string {
const p = JSON.parse(readFile(`${path}/package.json`));
p['dependencies']['@ngrx/store'] = '4.0.2';
p['dependencies']['@ngrx/effects'] = '4.0.2';
p['dependencies']['jasmine-marbles'] = '0.1.0';
updateFile(`${path}/package.json`, JSON.stringify(p, null, 2));
return runCommand('npm install', {cwd: path});
}

View File

@ -7,12 +7,16 @@
"build": "./scripts/build.sh",
"e2e": "yarn build && ./scripts/e2e.sh"
},
"dependencies" :{
"rxjs": "5.4.2",
"jasmine-marbles": "0.1.0"
},
"devDependencies": {
"typescript": "2.4.2",
"@types/node": "8.0.7",
"@types/jasmine": "2.5.53",
"@angular-devkit/core": "0.0.10",
"@angular-devkit/schematics": "0.0.16",
"@angular/cli": "1.3.0",
"jest": "20.0.4"
},
"author": "Victor Savkin",

View File

@ -1,6 +1,5 @@
#!/bin/bash
rm -rf build
rm -rf tmp
tsc
rsync -a --exclude=*.ts src/ build/src

View File

@ -0,0 +1,2 @@
export { readAll } from './utils/testing';
export { hot, cold } from 'jasmine-marbles';

View File

@ -1,15 +1,15 @@
import {TalksAndFiltersEffects} from './talks-and-filters.effects';
import {readAll} from '@nrwl/ext';
import {Actions} from '@ngrx/effects';
import {readAll, hot} from '@nrwl/ext';
import {<%= className %>Effects} from './<%= fileName %>.effects';
import {of} from 'rxjs/observable/of';
import {hot} from 'jasmine-marbles';
describe('<%= className %>Effects', () => {
describe('someEffect', () => {
it('should work', async () => {
const actions = new Actions(hot('-a-|', {a: {type: 'SOME_ACTION'}}));
const effects = new <%= className %>Effects();
const effects = new <%= className %>Effects(actions);
expect(await readAll(effects.navigateToTalk)).toEqual([
expect(await readAll(effects.someEffect)).toEqual([
{type: 'OTHER_ACTION'}
]);
});

View File

@ -1,6 +1,7 @@
import {Injectable} from '@angular/core';
import {Effect, Actions} from '@ngrx/effects';
import {of} from 'rxjs/observable/of';
import 'rxjs/add/operator/switchMap';
@Injectable()
export class <%= className %>Effects {

View File

@ -1,15 +1,95 @@
import {apply, branchAndMerge, chain, mergeWith, Rule, template, Tree, url} from '@angular-devkit/schematics';
import { names } from "../name-utils";
import {apply, branchAndMerge, chain, mergeWith, move, Rule, template, Tree, url} from '@angular-devkit/schematics';
import {names, toClassName, toFileName, toPropertyName} from "../name-utils";
import * as path from 'path';
import * as ts from 'typescript';
import {addImportToModule, addProviderToModule} from '../utility/ast-utils';
import {InsertChange} from '../utility/change';
import {insertImport} from '../utility/route-utils';
function addImportsToModule(name: string, options: any): Rule {
return (host: Tree) => {
if (options.skipImport) {
return host;
}
if (!host.exists(options.module)) {
throw new Error('Specified module does not exist');
}
const modulePath = options.module;
const sourceText = host.read(modulePath) !.toString('utf-8');
const source = ts.createSourceFile(modulePath, sourceText, ts.ScriptTarget.Latest, true);
if (options.emptyRoot) {
const reducer = `StoreModule.forRoot({})`;
const changes = [
insertImport(source, modulePath, 'StoreModule', '@ngrx/store'),
...addImportToModule(source, modulePath, reducer)
];
const declarationRecorder = host.beginUpdate(modulePath);
for (const change of changes) {
if (change instanceof InsertChange) {
declarationRecorder.insertLeft(change.pos, change.toAdd);
}
}
host.commitUpdate(declarationRecorder);
return host;
} else {
const reducerPath = `./+state/${toFileName(name)}.reducer`;
const effectsPath = `./+state/${toFileName(name)}.effects`;
const initPath = `./+state/${toFileName(name)}.init`;
const reducerName = `${toPropertyName(name)}Reducer`;
const effectsName = `${toClassName(name)}Effects`;
const initName = `${toPropertyName(name)}InitialState`;
const effects = options.root ? `EffectsModule.forRoot([${effectsName}])` : `EffectsModule.forFeature([${effectsName}])`;
const reducer = options.root ? `StoreModule.forRoot(${reducerName}, {initialState: ${initName}})` : `StoreModule.forFeature('${toPropertyName(name)}', ${reducerName}, {initialState: ${initName}})`;
const changes = [
insertImport(source, modulePath, 'StoreModule', '@ngrx/store'),
insertImport(source, modulePath, 'EffectsModule', '@ngrx/effects'),
insertImport(source, modulePath, reducerName, reducerPath),
insertImport(source, modulePath, initName, initPath),
insertImport(source, modulePath, effectsName, effectsPath),
...addImportToModule(source, modulePath, reducer),
...addImportToModule(source, modulePath, effects),
...addProviderToModule(source, modulePath, effectsName)
];
const declarationRecorder = host.beginUpdate(modulePath);
for (const change of changes) {
if (change instanceof InsertChange) {
declarationRecorder.insertLeft(change.pos, change.toAdd);
}
}
host.commitUpdate(declarationRecorder);
return host;
}
};
}
export default function (options: any): Rule {
const name = path.basename(options.module, ".module.ts");
const moduleDir = path.dirname(options.module);
const templateSource = apply(url('./files'), [
template({...options, tmpl: '', ...names('some')})
]);
return chain([
branchAndMerge(chain([
mergeWith(templateSource)
])),
]);
if (options.emptyRoot) {
return chain([
addImportsToModule(name, options)
]);
} else {
const templateSource = apply(url('./files'), [
template({...options, tmpl: '', ...names(name)}),
move(moduleDir)
]);
return chain([
branchAndMerge(chain([
mergeWith(templateSource)
])),
addImportsToModule(name, options)
]);
}
}

View File

@ -4,11 +4,20 @@
"title": "Add NgRx support to a module",
"type": "object",
"properties": {
"path": {
"module": {
"type": "string"
},
"skipImport": {
"type": "boolean"
},
"root": {
"type": "boolean"
},
"emptyRoot": {
"type": "boolean"
}
},
"required": [
"path"
"module"
]
}
}

View File

@ -7,17 +7,17 @@ export function names(name: string): any {
};
}
function toClassName(str: string): string {
export function toClassName(str: string): string {
return toCapitalCase(toPropertyName(str));
}
function toPropertyName(s: string): string {
export function toPropertyName(s: string): string {
return s
.replace(/(-|_|\.|\s)+(.)?/g, (_, __, chr) => chr ? chr.toUpperCase() : '')
.replace(/^([A-Z])/, m => m.toLowerCase());
}
function toFileName(s: string): string {
export function toFileName(s: string): string {
return s.replace(/([a-z\d])([A-Z])/g, '$1_$2').toLowerCase().replace(/[ _]/g, '-');
}

View File

@ -0,0 +1,353 @@
/**
* @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 * as ts from 'typescript';
import { Change, InsertChange } from './change';
import { insertImport } from './route-utils';
/**
* Find all nodes from the AST in the subtree of node of SyntaxKind kind.
* @param node
* @param kind
* @param max The maximum number of items to return.
* @return all nodes of kind, or [] if none is found
*/
export function findNodes(node: ts.Node, kind: ts.SyntaxKind, max = Infinity): ts.Node[] {
if (!node || max == 0) {
return [];
}
const arr: ts.Node[] = [];
if (node.kind === kind) {
arr.push(node);
max--;
}
if (max > 0) {
for (const child of node.getChildren()) {
findNodes(child, kind, max).forEach(node => {
if (max > 0) {
arr.push(node);
}
max--;
});
if (max <= 0) {
break;
}
}
}
return arr;
}
/**
* Get all the nodes from a source.
* @param sourceFile The source file object.
* @returns {Observable<ts.Node>} An observable of all the nodes in the source.
*/
export function getSourceNodes(sourceFile: ts.SourceFile): ts.Node[] {
const nodes: ts.Node[] = [sourceFile];
const result = [];
while (nodes.length > 0) {
const node = nodes.shift();
if (node) {
result.push(node);
if (node.getChildCount(sourceFile) >= 0) {
nodes.unshift(...node.getChildren());
}
}
}
return result;
}
/**
* Helper for sorting nodes.
* @return function to sort nodes in increasing order of position in sourceFile
*/
function nodesByPosition(first: ts.Node, second: ts.Node): number {
return first.pos - second.pos;
}
/**
* Insert `toInsert` after the last occurence of `ts.SyntaxKind[nodes[i].kind]`
* or after the last of occurence of `syntaxKind` if the last occurence is a sub child
* of ts.SyntaxKind[nodes[i].kind] and save the changes in file.
*
* @param nodes insert after the last occurence of nodes
* @param toInsert string to insert
* @param file file to insert changes into
* @param fallbackPos position to insert if toInsert happens to be the first occurence
* @param syntaxKind the ts.SyntaxKind of the subchildren to insert after
* @return Change instance
* @throw Error if toInsert is first occurence but fall back is not set
*/
export function insertAfterLastOccurrence(nodes: ts.Node[],
toInsert: string,
file: string,
fallbackPos: number,
syntaxKind?: ts.SyntaxKind): Change {
let lastItem = nodes.sort(nodesByPosition).pop();
if (syntaxKind) {
lastItem = findNodes(lastItem !, syntaxKind).sort(nodesByPosition).pop();
}
if (!lastItem && fallbackPos == undefined) {
throw new Error(`tried to insert ${toInsert} as first occurence with no fallback position`);
}
const lastItemPosition: number = lastItem ? lastItem.end : fallbackPos;
return new InsertChange(file, lastItemPosition, toInsert);
}
export function getContentOfKeyLiteral(_source: ts.SourceFile, node: ts.Node): string | null {
if (node.kind == ts.SyntaxKind.Identifier) {
return (node as ts.Identifier).text;
} else if (node.kind == ts.SyntaxKind.StringLiteral) {
return (node as ts.StringLiteral).text;
} else {
return null;
}
}
function _angularImportsFromNode(node: ts.ImportDeclaration,
_sourceFile: ts.SourceFile): {[name: string]: string} {
const ms = node.moduleSpecifier;
let modulePath: string | null = null;
switch (ms.kind) {
case ts.SyntaxKind.StringLiteral:
modulePath = (ms as ts.StringLiteral).text;
break;
default:
return {};
}
if (!modulePath.startsWith('@angular/')) {
return {};
}
if (node.importClause) {
if (node.importClause.name) {
// This is of the form `import Name from 'path'`. Ignore.
return {};
} else if (node.importClause.namedBindings) {
const nb = node.importClause.namedBindings;
if (nb.kind == ts.SyntaxKind.NamespaceImport) {
// This is of the form `import * as name from 'path'`. Return `name.`.
return {
[(nb as ts.NamespaceImport).name.text + '.']: modulePath,
};
} else {
// This is of the form `import {a,b,c} from 'path'`
const namedImports = nb as ts.NamedImports;
return namedImports.elements
.map((is: ts.ImportSpecifier) => is.propertyName ? is.propertyName.text : is.name.text)
.reduce((acc: {[name: string]: string}, curr: string) => {
acc[curr] = modulePath !;
return acc;
}, {});
}
}
return {};
} else {
// This is of the form `import 'path';`. Nothing to do.
return {};
}
}
export function getDecoratorMetadata(source: ts.SourceFile, identifier: string,
module: string): ts.Node[] {
const angularImports: {[name: string]: string}
= findNodes(source, ts.SyntaxKind.ImportDeclaration)
.map((node: ts.ImportDeclaration) => _angularImportsFromNode(node, source))
.reduce((acc: {[name: string]: string}, current: {[name: string]: string}) => {
for (const key of Object.keys(current)) {
acc[key] = current[key];
}
return acc;
}, {});
return getSourceNodes(source)
.filter(node => {
return node.kind == ts.SyntaxKind.Decorator
&& (node as ts.Decorator).expression.kind == ts.SyntaxKind.CallExpression;
})
.map(node => (node as ts.Decorator).expression as ts.CallExpression)
.filter(expr => {
if (expr.expression.kind == ts.SyntaxKind.Identifier) {
const id = expr.expression as ts.Identifier;
return id.getFullText(source) == identifier
&& angularImports[id.getFullText(source)] === module;
} else if (expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression) {
// This covers foo.NgModule when importing * as foo.
const paExpr = expr.expression as ts.PropertyAccessExpression;
// If the left expression is not an identifier, just give up at that point.
if (paExpr.expression.kind !== ts.SyntaxKind.Identifier) {
return false;
}
const id = paExpr.name.text;
const moduleId = (paExpr.expression as ts.Identifier).getText(source);
return id === identifier && (angularImports[moduleId + '.'] === module);
}
return false;
})
.filter(expr => expr.arguments[0]
&& expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression)
.map(expr => expr.arguments[0] as ts.ObjectLiteralExpression);
}
function _addSymbolToNgModuleMetadata(source: ts.SourceFile,
ngModulePath: string, metadataField: string,
expression: string): Change[] {
const nodes = getDecoratorMetadata(source, 'NgModule', '@angular/core');
let node: any = nodes[0]; // tslint:disable-line:no-any
// Find the decorator declaration.
if (!node) {
return [];
}
// Get all the children property assignment of object literals.
const matchingProperties: ts.ObjectLiteralElement[] =
(node as ts.ObjectLiteralExpression).properties
.filter(prop => prop.kind == ts.SyntaxKind.PropertyAssignment)
// Filter out every fields that's not "metadataField". Also handles string literals
// (but not expressions).
.filter((prop: ts.PropertyAssignment) => {
const name = prop.name;
switch (name.kind) {
case ts.SyntaxKind.Identifier:
return (name as ts.Identifier).getText(source) == metadataField;
case ts.SyntaxKind.StringLiteral:
return (name as ts.StringLiteral).text == metadataField;
}
return false;
});
// Get the last node of the array literal.
if (!matchingProperties) {
return [];
}
if (matchingProperties.length == 0) {
// We haven't found the field in the metadata declaration. Insert a new field.
const expr = node as ts.ObjectLiteralExpression;
let position: number;
let toInsert: string;
if (expr.properties.length == 0) {
position = expr.getEnd() - 1;
toInsert = ` ${metadataField}: [${expression}]\n`;
} else {
node = expr.properties[expr.properties.length - 1];
position = node.getEnd();
// Get the indentation of the last element, if any.
const text = node.getFullText(source);
if (text.match('^\r?\r?\n')) {
toInsert = `,${text.match(/^\r?\n\s+/)[0]}${metadataField}: [${expression}]`;
} else {
toInsert = `, ${metadataField}: [${expression}]`;
}
}
const newMetadataProperty = new InsertChange(ngModulePath, position, toInsert);
return [newMetadataProperty];
}
const assignment = matchingProperties[0] as ts.PropertyAssignment;
// If it's not an array, nothing we can do really.
if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) {
return [];
}
const arrLiteral = assignment.initializer as ts.ArrayLiteralExpression;
if (arrLiteral.elements.length == 0) {
// Forward the property.
node = arrLiteral;
} else {
node = arrLiteral.elements;
}
if (!node) {
console.log('No app module found. Please add your new class to your component.');
return [];
}
if (Array.isArray(node)) {
const nodeArray = node as {} as Array<ts.Node>;
const symbolsArray = nodeArray.map(node => node.getText());
if (symbolsArray.includes(expression)) {
return [];
}
node = node[node.length - 1];
}
let toInsert: string;
let position = node.getEnd();
if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) {
// We haven't found the field in the metadata declaration. Insert a new
// field.
const expr = node as ts.ObjectLiteralExpression;
if (expr.properties.length == 0) {
position = expr.getEnd() - 1;
toInsert = ` ${metadataField}: [${expression}]\n`;
} else {
node = expr.properties[expr.properties.length - 1];
position = node.getEnd();
// Get the indentation of the last element, if any.
const text = node.getFullText(source);
if (text.match('^\r?\r?\n')) {
toInsert = `,${text.match(/^\r?\n\s+/)[0]}${metadataField}: [${expression}]`;
} else {
toInsert = `, ${metadataField}: [${expression}]`;
}
}
} else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) {
// We found the field but it's empty. Insert it just before the `]`.
position--;
toInsert = `${expression}`;
} else {
// Get the indentation of the last element, if any.
const text = node.getFullText(source);
if (text.match(/^\r?\n/)) {
toInsert = `,${text.match(/^\r?\n(\r?)\s+/)[0]}${expression}`;
} else {
toInsert = `, ${expression}`;
}
}
const insert = new InsertChange(ngModulePath, position, toInsert);
return [insert];
}
export function addImportToModule(source: ts.SourceFile,
modulePath: string, symbolName: string): Change[] {
return _addSymbolToNgModuleMetadata(source, modulePath, 'imports', symbolName,);
}
export function addProviderToModule(source: ts.SourceFile,
modulePath: string, symbolName: string): Change[] {
return _addSymbolToNgModuleMetadata(source, modulePath, 'providers', symbolName);
}

View File

@ -0,0 +1,127 @@
/**
* @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
*/
export interface Host {
write(path: string, content: string): Promise<void>;
read(path: string): Promise<string>;
}
export interface Change {
apply(host: Host): Promise<void>;
// The file this change should be applied to. Some changes might not apply to
// a file (maybe the config).
readonly path: string | null;
// The order this change should be applied. Normally the position inside the file.
// Changes are applied from the bottom of a file to the top.
readonly order: number;
// The description of this change. This will be outputted in a dry or verbose run.
readonly description: string;
}
/**
* An operation that does nothing.
*/
export class NoopChange implements Change {
description = 'No operation.';
order = Infinity;
path = null;
apply() { return Promise.resolve(); }
}
/**
* Will add text to the source code.
*/
export class InsertChange implements Change {
order: number;
description: string;
constructor(public path: string, public pos: number, public toAdd: string) {
if (pos < 0) {
throw new Error('Negative positions are invalid');
}
this.description = `Inserted ${toAdd} into position ${pos} of ${path}`;
this.order = pos;
}
/**
* This method does not insert spaces if there is none in the original string.
*/
apply(host: Host) {
return host.read(this.path).then(content => {
const prefix = content.substring(0, this.pos);
const suffix = content.substring(this.pos);
return host.write(this.path, `${prefix}${this.toAdd}${suffix}`);
});
}
}
/**
* Will remove text from the source code.
*/
export class RemoveChange implements Change {
order: number;
description: string;
constructor(public path: string, private pos: number, private toRemove: string) {
if (pos < 0) {
throw new Error('Negative positions are invalid');
}
this.description = `Removed ${toRemove} into position ${pos} of ${path}`;
this.order = pos;
}
apply(host: Host): Promise<void> {
return host.read(this.path).then(content => {
const prefix = content.substring(0, this.pos);
const suffix = content.substring(this.pos + this.toRemove.length);
// TODO: throw error if toRemove doesn't match removed string.
return host.write(this.path, `${prefix}${suffix}`);
});
}
}
/**
* Will replace text from the source code.
*/
export class ReplaceChange implements Change {
order: number;
description: string;
constructor(public path: string, private pos: number, private oldText: string,
private newText: string) {
if (pos < 0) {
throw new Error('Negative positions are invalid');
}
this.description = `Replaced ${oldText} into position ${pos} of ${path} with ${newText}`;
this.order = pos;
}
apply(host: Host): Promise<void> {
return host.read(this.path).then(content => {
const prefix = content.substring(0, this.pos);
const suffix = content.substring(this.pos + this.oldText.length);
const text = content.substring(this.pos, this.pos + this.oldText.length);
if (text !== this.oldText) {
return Promise.reject(new Error(`Invalid replace: "${text}" != "${this.oldText}".`));
}
// TODO: throw error if oldText doesn't match removed string.
return host.write(this.path, `${prefix}${this.newText}${suffix}`);
});
}
}

View File

@ -0,0 +1,65 @@
/**
* @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 { Tree, normalizePath } from '@angular-devkit/schematics';
import * as path from 'path';
/**
* Function to find the "closest" module to a generated file's path.
*/
export function findModule(host: Tree, generateDir: string): string {
let closestModule = generateDir;
const allFiles = host.files;
let modulePath: string | null = null;
const moduleRe = /\.module\.ts$/;
while (closestModule) {
const normalizedRoot = normalizePath(closestModule);
const matches = allFiles.filter(p => moduleRe.test(p) && p.startsWith(normalizedRoot));
if (matches.length == 1) {
modulePath = matches[0];
break;
} else if (matches.length > 1) {
throw new Error('More than one module matches. Use skip-import option to skip importing '
+ 'the component into the closest module.');
}
closestModule = closestModule.split('/').slice(0, -1).join('/');
}
if (!modulePath) {
throw new Error('Could not find an NgModule for the new component. Use the skip-import '
+ 'option to skip importing components in NgModule.');
}
return modulePath;
}
/**
* Build a relative path from one file path to another file path.
*/
export function buildRelativePath(from: string, to: string) {
// Convert to arrays.
const fromParts = from.split('/');
const toParts = to.split('/');
// Remove file names (preserving destination)
fromParts.pop();
const toFileName = toParts.pop();
const relativePath = path.relative(fromParts.join('/'), toParts.join('/'));
let pathPrefix = '';
// Set the path prefix for same dir or child dir, parent dir starts with `..`
if (!relativePath) {
pathPrefix = '.';
} else if (!relativePath.startsWith('.')) {
pathPrefix = `./`;
}
return `${pathPrefix}${relativePath}/${toFileName}`;
}

View File

@ -0,0 +1,90 @@
/**
* @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 * as ts from 'typescript';
import { findNodes, insertAfterLastOccurrence } from './ast-utils';
import { Change, NoopChange } from './change';
/**
* Add Import `import { symbolName } from fileName` if the import doesn't exit
* already. Assumes fileToEdit can be resolved and accessed.
* @param fileToEdit (file we want to add import to)
* @param symbolName (item to import)
* @param fileName (path to the file)
* @param isDefault (if true, import follows style for importing default exports)
* @return Change
*/
export function insertImport(source: ts.SourceFile, fileToEdit: string, symbolName: string,
fileName: string, isDefault = false): Change {
const rootNode = source;
const allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
// get nodes that map to import statements from the file fileName
const relevantImports = allImports.filter(node => {
// StringLiteral of the ImportDeclaration is the import file (fileName in this case).
const importFiles = node.getChildren()
.filter(child => child.kind === ts.SyntaxKind.StringLiteral)
.map(n => (n as ts.StringLiteral).text);
return importFiles.filter(file => file === fileName).length === 1;
});
if (relevantImports.length > 0) {
let importsAsterisk = false;
// imports from import file
const imports: ts.Node[] = [];
relevantImports.forEach(n => {
Array.prototype.push.apply(imports, findNodes(n, ts.SyntaxKind.Identifier));
if (findNodes(n, ts.SyntaxKind.AsteriskToken).length > 0) {
importsAsterisk = true;
}
});
// if imports * from fileName, don't add symbolName
if (importsAsterisk) {
return new NoopChange();
}
const importTextNodes = imports.filter(n => (n as ts.Identifier).text === symbolName);
// insert import if it's not there
if (importTextNodes.length === 0) {
const fallbackPos = findNodes(relevantImports[0], ts.SyntaxKind.CloseBraceToken)[0].pos ||
findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].pos;
return insertAfterLastOccurrence(imports, `, ${symbolName}`, fileToEdit, fallbackPos);
}
return new NoopChange();
}
// no such import declaration exists
const useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral)
.filter((n: ts.StringLiteral) => n.text === 'use strict');
let fallbackPos = 0;
if (useStrict.length > 0) {
fallbackPos = useStrict[0].end;
}
const open = isDefault ? '' : '{ ';
const close = isDefault ? '' : ' }';
// if there are no imports or 'use strict' statement, insert import at beginning of file
const insertAtBeginning = allImports.length === 0 && useStrict.length === 0;
const separator = insertAtBeginning ? '' : ';\n';
const toInsert = `${separator}import ${open}${symbolName}${close}` +
` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`;
return insertAfterLastOccurrence(
allImports,
toInsert,
fileToEdit,
fallbackPos,
ts.SyntaxKind.StringLiteral,
);
}

7
src/utils/testing.ts Normal file
View File

@ -0,0 +1,7 @@
import {Observable} from 'rxjs/Observable';
import {toPromise} from 'rxjs/operator/toPromise';
import {toArray} from 'rxjs/operator/toArray';
export function readAll<T>(o: Observable<T>): Promise<T[]> {
return toPromise.call(toArray.call(o));
}

View File

@ -7,7 +7,7 @@
"typeRoots": ["node_modules/@types"],
"skipLibCheck": true,
"lib": [
"es2015"
"es2017"
]
},
"exclude": [

805
yarn.lock

File diff suppressed because it is too large Load Diff