feature: splitting the ast into its own packages (#1828)

This commit is contained in:
Hans 2016-08-24 19:54:26 -07:00 committed by GitHub
parent f9df8bb173
commit b5e86c9fa9
24 changed files with 1540 additions and 1363 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
dist/
node_modules/
npm-debug.log

View File

@ -12,6 +12,7 @@ env:
- DBUS_SESSION_BUS_ADDRESS=/dev/null
matrix:
- SCRIPT=lint
# - SCRIPT=build
- SCRIPT=test
# - TARGET=mobile SCRIPT=mobile_test
matrix:
@ -21,8 +22,13 @@ matrix:
- os: osx
node_js: "5"
env: SCRIPT=lint
# - os: osx
# env: TARGET=mobile SCRIPT=mobile_test
- node_js: "6"
env: SCRIPT=build
- os: osx
node_js: "5"
env: SCRIPT=build
- os: osx
env: TARGET=mobile SCRIPT=mobile_test
before_install:
- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew update; fi

View File

@ -13,7 +13,10 @@
"sourceMap": true,
"sourceRoot": "/",
"target": "es5",
"lib": ["es6"]
"lib": ["es6"],
"paths": {
"@angular-cli/ast-tools": [ "../../packages/ast-tools/src" ]
}
},
"includes": [
"./custom-typings.d.ts"

View File

@ -1,304 +1,12 @@
import * as ts from 'typescript';
import * as fs from 'fs';
import {Symbols} from '@angular/tsc-wrapped/src/symbols';
import {
isMetadataImportedSymbolReferenceExpression,
isMetadataModuleReferenceExpression
} from '@angular/tsc-wrapped';
import {Change, InsertChange, NoopChange, MultiChange} from './change';
import {insertImport} from './route-utils';
import {Observable} from 'rxjs/Observable';
import {ReplaySubject} from 'rxjs/ReplaySubject';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/last';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/mergeMap';
import 'rxjs/add/operator/toArray';
import 'rxjs/add/operator/toPromise';
/**
* Get TS source file based on path.
* @param filePath
* @return source file of ts.SourceFile kind
*/
export function getSource(filePath: string): ts.SourceFile {
return ts.createSourceFile(filePath, fs.readFileSync(filePath).toString(),
ts.ScriptTarget.ES6, true);
}
/**
* Get all the nodes from a source, as an observable.
* @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): Observable<ts.Node> {
const subject = new ReplaySubject<ts.Node>();
let nodes: ts.Node[] = [sourceFile];
while(nodes.length > 0) {
const node = nodes.shift();
if (node) {
subject.next(node);
if (node.getChildCount(sourceFile) >= 0) {
nodes.unshift(...node.getChildren());
}
}
}
subject.complete();
return subject.asObservable();
}
/**
* 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: number = Infinity): ts.Node[] {
if (!node || max == 0) {
return [];
}
let 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;
}
/**
* 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 {
var 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`);
}
let lastItemPosition: number = lastItem ? lastItem.end : fallbackPos;
return new InsertChange(file, lastItemPosition, toInsert);
}
export function getContentOfKeyLiteral(source: ts.SourceFile, node: ts.Node): string {
if (node.kind == ts.SyntaxKind.Identifier) {
return (<ts.Identifier>node).text;
} else if (node.kind == ts.SyntaxKind.StringLiteral) {
try {
return JSON.parse(node.getFullText(source))
} catch (e) {
return null;
}
} else {
return null;
}
}
export function getDecoratorMetadata(source: ts.SourceFile, identifier: string,
module: string): Observable<ts.Node> {
const symbols = new Symbols(source);
return getSourceNodes(source)
.filter(node => {
return node.kind == ts.SyntaxKind.Decorator
&& (<ts.Decorator>node).expression.kind == ts.SyntaxKind.CallExpression;
})
.map(node => <ts.CallExpression>(<ts.Decorator>node).expression)
.filter(expr => {
if (expr.expression.kind == ts.SyntaxKind.Identifier) {
const id = <ts.Identifier>expr.expression;
const metaData = symbols.resolve(id.getFullText(source));
if (isMetadataImportedSymbolReferenceExpression(metaData)) {
return metaData.name == identifier && metaData.module == module;
}
} else if (expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression) {
// This covers foo.NgModule when importing * as foo.
const paExpr = <ts.PropertyAccessExpression>expr.expression;
// 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;
const moduleId = <ts.Identifier>paExpr.expression;
const moduleMetaData = symbols.resolve(moduleId.getFullText(source));
if (isMetadataModuleReferenceExpression(moduleMetaData)) {
return moduleMetaData.module == module && id.getFullText(source) == identifier;
}
}
return false;
})
.filter(expr => expr.arguments[0]
&& expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression)
.map(expr => <ts.ObjectLiteralExpression>expr.arguments[0]);
}
function _addSymbolToNgModuleMetadata(ngModulePath: string, metadataField: string,
symbolName: string, importPath: string) {
const source: ts.SourceFile = getSource(ngModulePath);
let metadata = getDecoratorMetadata(source, 'NgModule', '@angular/core');
// Find the decorator declaration.
return metadata
.toPromise()
.then((node: ts.ObjectLiteralExpression) => {
if (!node) {
return null;
}
// Get all the children property assignment of object literals.
return node.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 => {
switch (prop.name.kind) {
case ts.SyntaxKind.Identifier:
return prop.name.getText(source) == metadataField;
case ts.SyntaxKind.StringLiteral:
return prop.name.text == metadataField;
}
return false;
});
})
// Get the last node of the array literal.
.then(matchingProperties => {
if (!matchingProperties) {
return;
}
if (matchingProperties.length == 0) {
return metadata
.toPromise();
}
const assignment = <ts.PropertyAssignment>matchingProperties[0];
// If it's not an array, nothing we can do really.
if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) {
return Observable.empty();
}
const arrLiteral = <ts.ArrayLiteralExpression>assignment.initializer;
if (arrLiteral.elements.length == 0) {
// Forward the property.
return arrLiteral;
}
return arrLiteral.elements;
})
.then((node: ts.Node) => {
if (!node) {
console.log('No app module found. Please add your new class to your component.');
return new NoopChange();
}
if (Array.isArray(node)) {
node = node[node.length - 1];
}
let toInsert;
let position = node.getEnd();
if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) {
// We haven't found the field in the metadata declaration. Insert a new
// field.
let expr = <ts.ObjectLiteralExpression>node;
if (expr.properties.length == 0) {
position = expr.getEnd() - 1;
toInsert = ` ${metadataField}: [${symbolName}]\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.startsWith('\n')) {
toInsert = `,${text.match(/^\n(\r?)\s+/)[0]}${metadataField}: [${symbolName}]`;
} else {
toInsert = `, ${metadataField}: [${symbolName}]`;
}
}
} else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) {
// We found the field but it's empty. Insert it just before the `]`.
position--;
toInsert = `${symbolName}`;
} else {
// Get the indentation of the last element, if any.
const text = node.getFullText(source);
if (text.startsWith('\n')) {
toInsert = `,${text.match(/^\n(\r?)\s+/)[0]}${symbolName}`;
} else {
toInsert = `, ${symbolName}`;
}
}
const insert = new InsertChange(ngModulePath, position, toInsert);
const importInsert: Change = insertImport(ngModulePath, symbolName, importPath);
return new MultiChange([insert, importInsert]);
});
}
/**
* Custom function to insert a declaration (component, pipe, directive)
* into NgModule declarations. It also imports the component.
*/
export function addComponentToModule(modulePath: string, classifiedName: string,
importPath: string): Promise<Change> {
return _addSymbolToNgModuleMetadata(modulePath, 'declarations', classifiedName, importPath);
}
/**
* Custom function to insert a provider into NgModule. It also imports it.
*/
export function addProviderToModule(modulePath: string, classifiedName: string,
importPath: string): Promise<Change> {
return _addSymbolToNgModuleMetadata(modulePath, 'providers', classifiedName, importPath);
}
// In order to keep refactoring low, simply export from ast-tools.
// TODO: move all dependencies of this file to ast-tools directly.
export {
getSource,
getSourceNodes,
findNodes,
insertAfterLastOccurrence,
getContentOfKeyLiteral,
getDecoratorMetadata,
addComponentToModule,
addProviderToModule
} from '@angular-cli/ast-tools';

View File

@ -1,166 +1,8 @@
'use strict';
import * as Promise from 'ember-cli/lib/ext/promise';
import fs = require('fs');
const readFile = Promise.denodeify(fs.readFile);
const writeFile = Promise.denodeify(fs.writeFile);
export interface Change {
apply(): Promise<void>;
// The file this change should be applied to. Some changes might not apply to
// a file (maybe the config).
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.
order: number;
// The description of this change. This will be outputted in a dry or verbose run.
description: string;
}
/**
* An operation that does nothing.
*/
export class NoopChange implements Change {
get description() { return 'No operation.'; }
get order() { return Infinity; }
get path() { return null; }
apply() { return Promise.resolve(); }
}
/**
* An operation that mixes two or more changes, and merge them (in order).
* Can only apply to a single file. Use a ChangeManager to apply changes to multiple
* files.
*/
export class MultiChange implements Change {
private _path: string;
private _changes: Change[];
constructor(...changes: Array<Change[], Change>) {
this._changes = [];
[].concat(...changes).forEach(change => this.appendChange(change));
}
appendChange(change: Change) {
// Validate that the path is the same for everyone of those.
if (this._path === undefined) {
this._path = change.path;
} else if (change.path !== this._path) {
throw new Error('Cannot apply a change to a different path.');
}
this._changes.push(change);
}
get description() {
return `Changes:\n ${this._changes.map(x => x.description).join('\n ')}`;
}
// Always apply as early as the highest change.
get order() { return Math.max(...this._changes); }
get path() { return this._path; }
apply() {
return this._changes
.sort((a: Change, b: Change) => b.order - a.order)
.reduce((promise, change) => {
return promise.then(() => change.apply())
}, Promise.resolve());
}
}
/**
* Will add text to the source code.
*/
export class InsertChange implements Change {
const order: number;
const description: string;
constructor(
public path: string,
private pos: number,
private 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(): Promise<any> {
return readFile(this.path, 'utf8').then(content => {
let prefix = content.substring(0, this.pos);
let suffix = content.substring(this.pos);
return writeFile(this.path, `${prefix}${this.toAdd}${suffix}`);
});
}
}
/**
* Will remove text from the source code.
*/
export class RemoveChange implements Change {
const order: number;
const 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(): Promise<any> {
return readFile(this.path, 'utf8').then(content => {
let prefix = content.substring(0, this.pos);
let suffix = content.substring(this.pos + this.toRemove.length);
// TODO: throw error if toRemove doesn't match removed string.
return writeFile(this.path, `${prefix}${suffix}`);
});
}
}
/**
* Will replace text from the source code.
*/
export class ReplaceChange implements Change {
const order: number;
const 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(): Promise<any> {
return readFile(this.path, 'utf8').then(content => {
let prefix = content.substring(0, this.pos);
let suffix = content.substring(this.pos + this.oldText.length);
// TODO: throw error if oldText doesn't match removed string.
return writeFile(this.path, `${prefix}${this.newText}${suffix}`);
});
}
}
export {
Change,
NoopChange,
MultiChange,
InsertChange,
RemoveChange,
ReplaceChange
} from '@angular-cli/ast-tools';

View File

@ -1,522 +1,11 @@
import * as ts from 'typescript';
import * as fs from 'fs';
import * as path from 'path';
import { Change, InsertChange } from './change';
import * as Promise from 'ember-cli/lib/ext/promise';
import {findNodes, insertAfterLastOccurrence } from './ast-utils';
/**
* Adds imports to mainFile and adds toBootstrap to the array of providers
* in bootstrap, if not present
* @param mainFile main.ts
* @param imports Object { importedClass: ['path/to/import/from', defaultStyleImport?] }
* @param toBootstrap
*/
export function bootstrapItem(mainFile, imports: {[key: string]: [string, boolean?]}, toBootstrap: string ) {
let changes = Object.keys(imports).map(importedClass => {
var defaultStyleImport = imports[importedClass].length === 2 && imports[importedClass][1];
return insertImport(mainFile, importedClass, imports[importedClass][0], defaultStyleImport);
});
let rootNode = getRootNode(mainFile);
// get ExpressionStatements from the top level syntaxList of the sourceFile
let bootstrapNodes = rootNode.getChildAt(0).getChildren().filter(node => {
// get bootstrap expressions
return node.kind === ts.SyntaxKind.ExpressionStatement &&
node.getChildAt(0).getChildAt(0).text.toLowerCase() === 'bootstrap';
});
if (bootstrapNodes.length !== 1) {
throw new Error(`Did not bootstrap provideRouter in ${mainFile}` +
' because of multiple or no bootstrap calls');
}
let bootstrapNode = bootstrapNodes[0].getChildAt(0);
let isBootstraped = findNodes(bootstrapNode, ts.SyntaxKind.SyntaxList) // get bootstrapped items
.reduce((a, b) => a.concat(b.getChildren().map(n => n.getText())), [])
.filter(n => n !== ',')
.indexOf(toBootstrap) !== -1;
if (isBootstraped) {
return changes;
}
// if bracket exitst already, add configuration template,
// otherwise, insert into bootstrap parens
var fallBackPos: number, configurePathsTemplate: string, separator: string;
var syntaxListNodes: any;
let bootstrapProviders = bootstrapNode.getChildAt(2).getChildAt(2); // array of providers
if ( bootstrapProviders ) {
syntaxListNodes = bootstrapProviders.getChildAt(1).getChildren();
fallBackPos = bootstrapProviders.getChildAt(2).pos; // closeBracketLiteral
separator = syntaxListNodes.length === 0 ? '' : ', ';
configurePathsTemplate = `${separator}${toBootstrap}`;
} else {
fallBackPos = bootstrapNode.getChildAt(3).pos; // closeParenLiteral
syntaxListNodes = bootstrapNode.getChildAt(2).getChildren();
configurePathsTemplate = `, [ ${toBootstrap} ]`;
}
changes.push(insertAfterLastOccurrence(syntaxListNodes, configurePathsTemplate,
mainFile, fallBackPos));
return changes;
}
/**
* 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(fileToEdit: string, symbolName: string,
fileName: string, isDefault = false): Change {
let rootNode = getRootNode(fileToEdit);
let allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
// get nodes that map to import statements from the file fileName
let relevantImports = allImports.filter(node => {
// StringLiteral of the ImportDeclaration is the import file (fileName in this case).
let importFiles = node.getChildren().filter(child => child.kind === ts.SyntaxKind.StringLiteral)
.map(n => (<ts.StringLiteralTypeNode>n).text);
return importFiles.filter(file => file === fileName).length === 1;
});
if (relevantImports.length > 0) {
var importsAsterisk = false;
// imports from import file
let 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;
}
let importTextNodes = imports.filter(n => (<ts.Identifier>n).text === symbolName);
// insert import if it's not there
if (importTextNodes.length === 0) {
let fallbackPos = findNodes(relevantImports[0], ts.SyntaxKind.CloseBraceToken)[0].pos ||
findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].pos;
return insertAfterLastOccurrence(imports, `, ${symbolName}`, fileToEdit, fallbackPos);
}
return;
}
// no such import declaration exists
let useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral)
.filter(n => n.text === 'use strict');
let fallbackPos = 0;
if (useStrict.length > 0) {
fallbackPos = useStrict[0].end;
}
let open = isDefault ? '' : '{ ';
let close = isDefault ? '' : ' }';
// if there are no imports or 'use strict' statement, insert import at beginning of file
let insertAtBeginning = allImports.length === 0 && useStrict.length === 0;
let separator = insertAtBeginning ? '' : ';\n';
let toInsert = `${separator}import ${open}${symbolName}${close}` +
` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`;
return insertAfterLastOccurrence(allImports, toInsert, fileToEdit, fallbackPos, ts.SyntaxKind.StringLiteral);
};
/**
* Inserts a path to the new route into src/routes.ts if it doesn't exist
* @param routesFile
* @param pathOptions
* @return Change[]
* @throws Error if routesFile has multiple export default or none.
*/
export function addPathToRoutes(routesFile: string, pathOptions: {[key: string]: any}): Change[] {
let route = pathOptions.route.split('/')
.filter(n => n !== '').join('/'); // change say `/about/:id/` to `about/:id`
let isDefault = pathOptions.isDefault ? ', useAsDefault: true' : '';
let outlet = pathOptions.outlet ? `, outlet: '${pathOptions.outlet}'` : '';
// create route path and resolve component import
let positionalRoutes = /\/:[^/]*/g;
let routePath = route.replace(positionalRoutes, '');
routePath = `./app/${routePath}/${pathOptions.dasherizedName}.component`;
let originalComponent = pathOptions.component;
pathOptions.component = resolveImportName(pathOptions.component, routePath, pathOptions.routesFile);
var content = `{ path: '${route}', component: ${pathOptions.component}${isDefault}${outlet} }`;
let rootNode = getRootNode(routesFile);
let routesNode = rootNode.getChildAt(0).getChildren().filter(n => {
// get export statement
return n.kind === ts.SyntaxKind.ExportAssignment &&
n.getFullText().indexOf('export default') !== -1;
});
if (routesNode.length !== 1) {
throw new Error('Did not insert path in routes.ts because ' +
`there were multiple or no 'export default' statements`);
}
var pos = routesNode[0].getChildAt(2).getChildAt(0).end; // openBracketLiteral
// all routes in export route array
let routesArray = routesNode[0].getChildAt(2).getChildAt(1)
.getChildren()
.filter(n => n.kind === ts.SyntaxKind.ObjectLiteralExpression);
if (pathExists(routesArray, route, pathOptions.component)) {
// don't duplicate routes
throw new Error('Route was not added since it is a duplicate');
}
var isChild = false;
// get parent to insert under
let parent;
if (pathOptions.parent) {
// append '_' to route to find the actual parent (not parent of the parent)
parent = getParent(routesArray, `${pathOptions.parent}/_`);
if (!parent) {
throw new Error(`You specified parent '${pathOptions.parent}'' which was not found in routes.ts`);
}
if (route.indexOf(pathOptions.parent) === 0) {
route = route.substring(pathOptions.parent.length);
}
} else {
parent = getParent(routesArray, route);
}
if (parent) {
let childrenInfo = addChildPath(parent, pathOptions, route);
if (!childrenInfo) {
// path exists already
throw new Error('Route was not added since it is a duplicate');
}
content = childrenInfo.newContent;
pos = childrenInfo.pos;
isChild = true;
}
let isFirstElement = routesArray.length === 0;
if (!isChild) {
let separator = isFirstElement ? '\n' : ',';
content = `\n ${content}${separator}`;
}
let changes: Change[] = [new InsertChange(routesFile, pos, content)];
let component = originalComponent === pathOptions.component ? originalComponent :
`${originalComponent} as ${pathOptions.component}`;
routePath = routePath.replace(/\\/, '/'); // correction in windows
changes.push(insertImport(routesFile, component, routePath));
return changes;
}
/**
* Add more properties to the route object in routes.ts
* @param routesFile routes.ts
* @param route Object {route: [key, value]}
*/
export function addItemsToRouteProperties(routesFile: string, routes: {[key: string]: [string, string]}) {
let rootNode = getRootNode(routesFile);
let routesNode = rootNode.getChildAt(0).getChildren().filter(n => {
// get export statement
return n.kind === ts.SyntaxKind.ExportAssignment &&
n.getFullText().indexOf('export default') !== -1;
});
if (routesNode.length !== 1) {
throw new Error('Did not insert path in routes.ts because ' +
`there were multiple or no 'export default' statements`);
}
let routesArray = routesNode[0].getChildAt(2).getChildAt(1)
.getChildren()
.filter(n => n.kind === ts.SyntaxKind.ObjectLiteralExpression);
let changes: Change[] = Object.keys(routes).reduce((result, route) => {
// let route = routes[guardName][0];
let itemKey = routes[route][0];
let itemValue = routes[route][1];
let currRouteNode = getParent(routesArray, `${route}/_`);
if (!currRouteNode) {
throw new Error(`Could not find '${route}' in routes.ts`);
}
let fallBackPos = findNodes(currRouteNode, ts.SyntaxKind.CloseBraceToken).pop().pos;
let pathPropertiesNodes = currRouteNode.getChildAt(1).getChildren()
.filter(n => n.kind === ts.SyntaxKind.PropertyAssignment);
return result.concat([insertAfterLastOccurrence(pathPropertiesNodes,
`, ${itemKey}: ${itemValue}`, routesFile, fallBackPos)]);
}, []);
return changes;
}
/**
* Verifies that a component file exports a class of the component
* @param file
* @param componentName
* @return whether file exports componentName
*/
export function confirmComponentExport (file: string, componentName: string): boolean {
const rootNode = getRootNode(file);
let exportNodes = rootNode.getChildAt(0).getChildren().filter(n => {
return n.kind === ts.SyntaxKind.ClassDeclaration &&
(n.getChildren().filter(p => p.text === componentName).length !== 0);
});
return exportNodes.length > 0;
}
/**
* Ensures there is no collision between import names. If a collision occurs, resolve by adding
* underscore number to the name
* @param importName
* @param importPath path to import component from
* @param fileName (file to add import to)
* @return resolved importName
*/
function resolveImportName (importName: string, importPath: string, fileName: string): string {
const rootNode = getRootNode(fileName);
// get all the import names
let importNodes = rootNode.getChildAt(0).getChildren()
.filter(n => n.kind === ts.SyntaxKind.ImportDeclaration);
// check if imported file is same as current one before updating component name
let importNames = importNodes
.reduce((a, b) => {
let importFrom = findNodes(b, ts.SyntaxKind.StringLiteral); // there's only one
if (importFrom.pop().text !== importPath) {
// importing from different file, add to imported components to inspect
// if only one identifier { FooComponent }, if two { FooComponent as FooComponent_1 }
// choose last element of identifier array in both cases
return a.concat([findNodes(b, ts.SyntaxKind.Identifier).pop()]);
}
return a;
}, [])
.map(n => n.text);
const index = importNames.indexOf(importName);
if (index === -1) {
return importName;
}
const baseName = importNames[index].split('_')[0];
var newName = baseName;
var resolutionNumber = 1;
while (importNames.indexOf(newName) !== -1) {
newName = `${baseName}_${resolutionNumber}`;
resolutionNumber++;
}
return newName;
}
/**
* Resolve a path to a component file. If the path begins with path.sep, it is treated to be
* absolute from the app/ directory. Otherwise, it is relative to currDir
* @param projectRoot
* @param currentDir
* @param filePath componentName or path to componentName
* @return component file name
* @throw Error if component file referenced by path is not found
*/
export function resolveComponentPath(projectRoot: string, currentDir: string, filePath: string) {
let parsedPath = path.parse(filePath);
let componentName = parsedPath.base.split('.')[0];
let componentDir = path.parse(parsedPath.dir).base;
// correction for a case where path is /**/componentName/componentName(.component.ts)
if ( componentName === componentDir) {
filePath = parsedPath.dir;
}
if (parsedPath.dir === '') {
// only component file name is given
filePath = componentName;
}
var directory = filePath[0] === path.sep ?
path.resolve(path.join(projectRoot, 'src', 'app', filePath)) : path.resolve(currentDir, filePath);
if (!fs.existsSync(directory)) {
throw new Error(`path '${filePath}' must be relative to current directory` +
` or absolute from project root`);
}
if (directory.indexOf('src' + path.sep + 'app') === -1) {
throw new Error('Route must be within app');
}
let componentFile = path.join(directory, `${componentName}.component.ts`);
if (!fs.existsSync(componentFile)) {
throw new Error(`could not find component file referenced by ${filePath}`);
}
return componentFile;
}
/**
* Sort changes in decreasing order and apply them.
* @param changes
* @return Promise
*/
export function applyChanges(changes: Change[]): Promise<void> {
return changes
.filter(change => !!change)
.sort((curr, next) => next.pos - curr.pos)
.reduce((newChange, change) => newChange.then(() => change.apply()), Promise.resolve());
}
/**
* Helper for addPathToRoutes. Adds child array to the appropriate position in the routes.ts file
* @return Object (pos, newContent)
*/
function addChildPath (parentObject: ts.Node, pathOptions: {[key: string]: any}, route: string) {
if (!parentObject) {
return;
}
var pos: number;
var newContent: string;
// get object with 'children' property
let childrenNode = parentObject.getChildAt(1).getChildren()
.filter(n => n.kind === ts.SyntaxKind.PropertyAssignment
&& n.getChildAt(0).text === 'children');
// find number of spaces to pad nested paths
let nestingLevel = 1; // for indenting route object in the `children` array
let n = parentObject;
while (n.parent) {
if (n.kind === ts.SyntaxKind.ObjectLiteralExpression
|| n.kind === ts.SyntaxKind.ArrayLiteralExpression) {
nestingLevel ++;
}
n = n.parent;
}
// strip parent route
let parentRoute = parentObject.getChildAt(1).getChildAt(0).getChildAt(2).text;
let childRoute = route.substring(route.indexOf(parentRoute) + parentRoute.length + 1);
let isDefault = pathOptions.isDefault ? ', useAsDefault: true' : '';
let outlet = pathOptions.outlet ? `, outlet: '${pathOptions.outlet}'` : '';
let content = `{ path: '${childRoute}', component: ${pathOptions.component}` +
`${isDefault}${outlet} }`;
let spaces = Array(2 * nestingLevel + 1).join(' ');
if (childrenNode.length !== 0) {
// add to beginning of children array
pos = childrenNode[0].getChildAt(2).getChildAt(1).pos; // open bracket
newContent = `\n${spaces}${content}, `;
} else {
// no children array, add one
pos = parentObject.getChildAt(2).pos; // close brace
newContent = `,\n${spaces.substring(2)}children: [\n${spaces}${content} ` +
`\n${spaces.substring(2)}]\n${spaces.substring(5)}`;
}
return {newContent: newContent, pos: pos};
}
/**
* Helper for addPathToRoutes.
* @return parentNode which contains the children array to add a new path to or
* undefined if none or the entire route was matched.
*/
function getParent(routesArray: ts.Node[], route: string, parent?: ts.Node): ts.Node {
if (routesArray.length === 0 && !parent) {
return; // no children array and no parent found
}
if (route.length === 0) {
return; // route has been completely matched
}
var splitRoute = route.split('/');
// don't treat positional parameters separately
if (splitRoute.length > 1 && splitRoute[1].indexOf(':') !== -1) {
let actualRoute = splitRoute.shift();
splitRoute[0] = `${actualRoute}/${splitRoute[0]}`;
}
let potentialParents: ts.Node[] = routesArray // route nodes with same path as current route
.filter(n => getValueForKey(n, 'path') === splitRoute[0]);
if (potentialParents.length !== 0) {
splitRoute.shift(); // matched current parent, move on
route = splitRoute.join('/');
}
// get all children paths
let newRouteArray = getChildrenArray(routesArray);
if (route && parent && potentialParents.length === 0) {
return parent; // final route is not matched. assign parent from here
}
parent = potentialParents.sort((a, b) => a.pos - b.pos).shift();
return getParent(newRouteArray, route, parent);
}
/**
* Helper for addPathToRoutes.
* @return whether path with same route and component exists
*/
function pathExists(routesArray: ts.Node[], route: string, component: string, fullRoute?: string): boolean {
if (routesArray.length === 0) {
return false;
}
fullRoute = fullRoute ? fullRoute : route;
var sameRoute = false;
var splitRoute = route.split('/');
// don't treat positional parameters separately
if (splitRoute.length > 1 && splitRoute[1].indexOf(':') !== -1) {
let actualRoute = splitRoute.shift();
splitRoute[0] = `${actualRoute}/${splitRoute[0]}`;
}
let repeatedRoutes: ts.Node[] = routesArray.filter(n => {
let currentRoute = getValueForKey(n, 'path');
let sameComponent = getValueForKey(n, 'component') === component;
sameRoute = currentRoute === splitRoute[0];
// Confirm that it's parents are the same
if (sameRoute && sameComponent) {
var path = currentRoute;
let objExp = n.parent;
while (objExp) {
if (objExp.kind === ts.SyntaxKind.ObjectLiteralExpression) {
let currentParentPath = getValueForKey(objExp, 'path');
path = currentParentPath ? `${currentParentPath}/${path}` : path;
}
objExp = objExp.parent;
}
return path === fullRoute;
}
return false;
});
if (sameRoute) {
splitRoute.shift(); // matched current parent, move on
route = splitRoute.join('/');
}
if (repeatedRoutes.length !== 0) {
return true; // new path will be repeating if inserted. report that path already exists
}
// all children paths
let newRouteArray = getChildrenArray(routesArray);
return pathExists(newRouteArray, route, component, fullRoute);
}
/**
* Helper for getParent and pathExists
* @return array with all nodes holding children array under routes
* in routesArray
*/
function getChildrenArray(routesArray: ts.Node[]): ts.Node[] {
return routesArray.reduce((allRoutes, currRoute) => allRoutes.concat(
currRoute.getChildAt(1).getChildren()
.filter(n => n.kind === ts.SyntaxKind.PropertyAssignment
&& n.getChildAt(0).text === 'children')
.map(n => n.getChildAt(2).getChildAt(1)) // syntaxList containing chilren paths
.reduce((childrenArray, currChild) => childrenArray.concat(currChild.getChildren()
.filter(p => p.kind === ts.SyntaxKind.ObjectLiteralExpression)
), [])
), []);
}
/**
* Helper method to get the path text or component
* @param objectLiteralNode
* @param key 'path' or 'component'
*/
function getValueForKey(objectLiteralNode: ts.TypeNode.ObjectLiteralExpression, key: string) {
let currentNode = key === 'component' ? objectLiteralNode.getChildAt(1).getChildAt(2) :
objectLiteralNode.getChildAt(1).getChildAt(0);
return currentNode && currentNode.getChildAt(0)
&& currentNode.getChildAt(0).text === key && currentNode.getChildAt(2)
&& currentNode.getChildAt(2).text;
}
/**
* Helper method to get AST from file
* @param file
*/
function getRootNode(file: string) {
return ts.createSourceFile(file, fs.readFileSync(file).toString(), ts.ScriptTarget.ES6, true);
}
// In order to keep refactoring low, simply export from ast-tools.
// TODO: move all dependencies of this file to ast-tools directly.
export {
bootstrapItem,
insertImport,
addPathToRoutes,
addItemsToRouteProperties,
confirmComponentExport,
resolveComponentPath,
applyChanges
} from '@angular-cli/ast-tools';

3
bin/ng
View File

@ -9,6 +9,9 @@ const exit = require('exit');
const packageJson = require('../package.json');
const Leek = require('leek');
require('../lib/bootstrap-local');
resolve('angular-cli', { basedir: process.cwd() },
function (error, projectLocalCli) {
var cli;

54
lib/bootstrap-local.js vendored Normal file
View File

@ -0,0 +1,54 @@
/* eslint-disable no-console */
'use strict';
const fs = require('fs');
const ts = require('typescript');
const oldRequireTs = require.extensions['.ts'];
require.extensions['.ts'] = function(m, filename) {
// If we're in node module, either call the old hook or simply compile the
// file without transpilation. We do not touch node_modules/**.
// We do touch `angular-cli` files anywhere though.
if (!filename.match(/angular-cli/) && filename.match(/node_modules/)) {
if (oldRequireTs) {
return oldRequireTs(m, filename);
}
return m._compile(fs.readFileSync(filename), filename);
}
// Node requires all require hooks to be sync.
const source = fs.readFileSync(filename).toString();
try {
const result = ts.transpile(source, {
target: ts.ScriptTarget.ES5,
module: ts.ModuleKind.CommonJs
});
// Send it to node to execute.
return m._compile(result, filename);
} catch (err) {
console.error('Error while running script "' + filename + '":');
console.error(err.stack);
throw err;
}
};
// If we're running locally, meaning npm linked. This is basically "developer mode".
if (!__dirname.match(/\/node_modules\//)) {
const packages = require('./packages');
// We mock the module loader so that we can fake our packages when running locally.
const Module = require('module');
const oldLoad = Module._load;
Module._load = function (request, parent) {
if (request in packages) {
return oldLoad.call(this, packages[request].main, parent);
} else {
return oldLoad.apply(this, arguments);
}
};
}

View File

@ -1,40 +1,6 @@
/*eslint-disable no-console */
// This file hooks up on require calls to transpile TypeScript.
const fs = require('fs');
const ts = require('typescript');
const old = require.extensions['.ts'];
require.extensions['.ts'] = function(m, filename) {
// If we're in node module, either call the old hook or simply compile the
// file without transpilation. We do not touch node_modules/**.
// We do touch `angular-cli` files anywhere though.
if (!filename.match(/angular-cli/) && filename.match(/node_modules/)) {
if (old) {
return old(m, filename);
}
return m._compile(fs.readFileSync(filename), filename);
}
// Node requires all require hooks to be sync.
const source = fs.readFileSync(filename).toString();
try {
const result = ts.transpile(source, {
target: ts.ScriptTarget.ES5,
module: ts.ModuleKind.CommonJs
});
// Send it to node to execute.
return m._compile(result, filename);
} catch (err) {
console.error('Error while running script "' + filename + '":');
console.error(err.stack);
throw err;
}
};
const cli = require('ember-cli/lib/cli');
const path = require('path');

19
lib/packages.js Normal file
View File

@ -0,0 +1,19 @@
const fs = require('fs');
const path = require('path');
const packageRoot = path.join(__dirname, '../packages');
// All the supported packages. Go through the packages directory and create a map of
// name => fullPath.
const packages = fs.readdirSync(packageRoot)
.map(pkgName => ({ name: pkgName, root: path.join(packageRoot, pkgName) }))
.filter(pkg => fs.statSync(pkg.root).isDirectory())
.reduce((packages, pkg) => {
packages[`@angular-cli/${pkg.name}`] = {
root: pkg.root,
main: path.resolve(pkg.root, 'src/index.ts')
};
return packages;
}, {});
module.exports = packages;

View File

@ -9,9 +9,12 @@
},
"keywords": [],
"scripts": {
"test": "node tests/runner",
"build": "for PKG in packages/*; do echo Building $PKG...; tsc -P $PKG; done",
"test": "npm run test:packages && npm run test:cli",
"mobile_test": "mocha tests/e2e/e2e_workflow.spec.js",
"test:cli": "node tests/runner",
"test:inspect": "node --inspect --debug-brk tests/runner",
"test:packages": "node scripts/run-packages-spec.js",
"lint": "eslint .",
"build-config-interface": "dtsgen lib/config/schema.json --out lib/config/schema.d.ts"
},
@ -35,9 +38,6 @@
"dependencies": {
"@angular/core": "^2.0.0-rc.5",
"@angular/tsc-wrapped": "^0.2.2",
"@types/lodash": "^4.0.25-alpha",
"@types/rimraf": "0.0.25-alpha",
"@types/webpack": "^1.12.22-alpha",
"angular2-template-loader": "^0.5.0",
"awesome-typescript-loader": "^2.2.1",
"chalk": "^1.1.3",
@ -90,7 +90,6 @@
"stylus-loader": "^2.1.0",
"symlink-or-copy": "^1.0.3",
"ts-loader": "^0.8.2",
"tslint": "^3.11.0",
"tslint-loader": "^2.1.4",
"typedoc": "^0.4.2",
"typescript": "^2.0.0",
@ -106,13 +105,21 @@
]
},
"devDependencies": {
"@types/denodeify": "^1.2.29",
"@types/jasmine": "^2.2.32",
"@types/lodash": "^4.0.25-alpha",
"@types/mock-fs": "3.6.28",
"@types/node": "^6.0.36",
"@types/rimraf": "0.0.25-alpha",
"@types/webpack": "^1.12.22-alpha",
"chai": "^3.5.0",
"conventional-changelog": "^1.1.0",
"denodeify": "^1.2.1",
"dtsgenerator": "^0.7.1",
"eslint": "^2.8.0",
"exists-sync": "0.0.3",
"jasmine": "^2.4.1",
"jasmine-spec-reporter": "^2.7.0",
"minimatch": "^3.0.0",
"mocha": "^2.4.5",
"mock-fs": "3.10.0",
@ -121,7 +128,7 @@
"sinon": "^1.17.3",
"through": "^2.3.8",
"tree-kill": "^1.0.0",
"tslint": "^3.8.1",
"tslint": "^3.11.0",
"walk-sync": "^0.2.6"
}
}

View File

@ -0,0 +1,27 @@
{
"name": "angular-cli",
"version": "1.0.0-beta.11-webpack.2",
"description": "CLI tool for Angular",
"main": "./index.js",
"keywords": [
"angular",
"cli",
"ast",
"tool"
],
"repository": {
"type": "git",
"url": "https://github.com/angular/angular-cli.git"
},
"author": "angular",
"license": "MIT",
"bugs": {
"url": "https://github.com/angular/angular-cli/issues"
},
"homepage": "https://github.com/angular/angular-cli",
"dependencies": {
"rxjs": "^5.0.0-beta.11",
"denodeify": "^1.2.1",
"typescript": "^2.0.0"
}
}

View File

@ -1,16 +1,15 @@
import * as mockFs from 'mock-fs';
import { expect } from 'chai';
import * as ts from 'typescript';
import * as fs from 'fs';
import { InsertChange, RemoveChange } from '../../addon/ng2/utilities/change';
import * as Promise from 'ember-cli/lib/ext/promise';
import {
findNodes,
insertAfterLastOccurrence,
addComponentToModule
} from '../../addon/ng2/utilities/ast-utils';
import denodeify = require('denodeify');
import mockFs = require('mock-fs');
import ts = require('typescript');
import fs = require('fs');
import {InsertChange, RemoveChange} from './change';
import {insertAfterLastOccurrence, addComponentToModule} from './ast-utils';
import {findNodes} from './node';
import {it} from './spec-utils';
const readFile = <any>denodeify(fs.readFile);
const readFile = Promise.denodeify(fs.readFile);
describe('ast-utils: findNodes', () => {
const sourceFile = 'tmp/tmp.ts';
@ -19,7 +18,7 @@ describe('ast-utils: findNodes', () => {
let mockDrive = {
'tmp': {
'tmp.ts': `import * as myTest from 'tests' \n` +
'hello.'
'hello.'
}
};
mockFs(mockDrive);
@ -32,42 +31,42 @@ describe('ast-utils: findNodes', () => {
it('finds no imports', () => {
let editedFile = new RemoveChange(sourceFile, 0, `import * as myTest from 'tests' \n`);
return editedFile
.apply()
.then(() => {
let rootNode = getRootNode(sourceFile);
let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
expect(nodes).to.be.empty;
});
.apply()
.then(() => {
let rootNode = getRootNode(sourceFile);
let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
expect(nodes).toEqual([]);
});
});
it('finds one import', () => {
let rootNode = getRootNode(sourceFile);
let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
expect(nodes.length).to.equal(1);
expect(nodes.length).toEqual(1);
});
it('finds two imports from inline declarations', () => {
// remove new line and add an inline import
let editedFile = new RemoveChange(sourceFile, 32, '\n');
return editedFile
.apply()
.then(() => {
let insert = new InsertChange(sourceFile, 32, `import {Routes} from '@angular/routes'`);
return insert.apply();
})
.then(() => {
let rootNode = getRootNode(sourceFile);
let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
expect(nodes.length).to.equal(2);
});
.apply()
.then(() => {
let insert = new InsertChange(sourceFile, 32, `import {Routes} from '@angular/routes'`);
return insert.apply();
})
.then(() => {
let rootNode = getRootNode(sourceFile);
let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
expect(nodes.length).toEqual(2);
});
});
it('finds two imports from new line separated declarations', () => {
let editedFile = new InsertChange(sourceFile, 33, `import {Routes} from '@angular/routes'`);
return editedFile
.apply()
.then(() => {
let rootNode = getRootNode(sourceFile);
let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
expect(nodes.length).to.equal(2);
});
.apply()
.then(() => {
let rootNode = getRootNode(sourceFile);
let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
expect(nodes.length).toEqual(2);
});
});
});
@ -89,86 +88,93 @@ describe('ast-utils: insertAfterLastOccurrence', () => {
it('inserts at beginning of file', () => {
let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile);
return insertAfterLastOccurrence(imports, `\nimport { Router } from '@angular/router';`,
sourceFile, 0)
.apply()
.then(() => {
return readFile(sourceFile, 'utf8');
}).then((content) => {
let expected = '\nimport { Router } from \'@angular/router\';';
expect(content).to.equal(expected);
});
sourceFile, 0)
.apply()
.then(() => {
return readFile(sourceFile, 'utf8');
}).then((content) => {
let expected = '\nimport { Router } from \'@angular/router\';';
expect(content).toEqual(expected);
});
});
it('throws an error if first occurence with no fallback position', () => {
let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile);
expect(() => insertAfterLastOccurrence(imports, `import { Router } from '@angular/router';`,
sourceFile)).to.throw(Error);
sourceFile)).toThrowError();
});
it('inserts after last import', () => {
let content = `import { foo, bar } from 'fizz';`;
let editedFile = new InsertChange(sourceFile, 0, content);
return editedFile
.apply()
.then(() => {
let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile);
return insertAfterLastOccurrence(imports, ', baz', sourceFile,
0, ts.SyntaxKind.Identifier)
.apply();
}).then(() => {
return readFile(sourceFile, 'utf8');
}).then(newContent => expect(newContent).to.equal(`import { foo, bar, baz } from 'fizz';`));
.apply()
.then(() => {
let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile);
return insertAfterLastOccurrence(imports, ', baz', sourceFile,
0, ts.SyntaxKind.Identifier)
.apply();
})
.then(() => {
return readFile(sourceFile, 'utf8');
})
.then(newContent => expect(newContent).toEqual(`import { foo, bar, baz } from 'fizz';`));
});
it('inserts after last import declaration', () => {
let content = `import * from 'foo' \n import { bar } from 'baz'`;
let editedFile = new InsertChange(sourceFile, 0, content);
return editedFile
.apply()
.then(() => {
let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile);
return insertAfterLastOccurrence(imports, `\nimport Router from '@angular/router'`,
sourceFile)
.apply();
}).then(() => {
return readFile(sourceFile, 'utf8');
}).then(newContent => {
let expected = `import * from 'foo' \n import { bar } from 'baz'` +
`\nimport Router from '@angular/router'`;
expect(newContent).to.equal(expected);
});
.apply()
.then(() => {
let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile);
return insertAfterLastOccurrence(imports, `\nimport Router from '@angular/router'`,
sourceFile)
.apply();
})
.then(() => {
return readFile(sourceFile, 'utf8');
})
.then(newContent => {
let expected = `import * from 'foo' \n import { bar } from 'baz'` +
`\nimport Router from '@angular/router'`;
expect(newContent).toEqual(expected);
});
});
it('inserts correctly if no imports', () => {
let content = `import {} from 'foo'`;
let editedFile = new InsertChange(sourceFile, 0, content);
return editedFile
.apply()
.then(() => {
let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile);
return insertAfterLastOccurrence(imports, ', bar', sourceFile, undefined,
ts.SyntaxKind.Identifier)
.apply();
}).catch(() => {
return readFile(sourceFile, 'utf8');
})
.then(newContent => {
expect(newContent).to.equal(content);
// use a fallback position for safety
let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile);
let pos = findNodes(imports.sort((a, b) => a.pos - b.pos).pop(),
ts.SyntaxKind.CloseBraceToken).pop().pos;
return insertAfterLastOccurrence(imports, ' bar ',
sourceFile, pos, ts.SyntaxKind.Identifier)
.apply();
}).then(() => {
return readFile(sourceFile, 'utf8');
}).then(newContent => {
expect(newContent).to.equal(`import { bar } from 'foo'`);
});
.apply()
.then(() => {
let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile);
return insertAfterLastOccurrence(imports, ', bar', sourceFile, undefined,
ts.SyntaxKind.Identifier)
.apply();
})
.catch(() => {
return readFile(sourceFile, 'utf8');
})
.then(newContent => {
expect(newContent).toEqual(content);
// use a fallback position for safety
let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile);
let pos = findNodes(imports.sort((a, b) => a.pos - b.pos).pop(),
ts.SyntaxKind.CloseBraceToken).pop().pos;
return insertAfterLastOccurrence(imports, ' bar ',
sourceFile, pos, ts.SyntaxKind.Identifier)
.apply();
})
.then(() => {
return readFile(sourceFile, 'utf8');
})
.then(newContent => {
expect(newContent).toEqual(`import { bar } from 'foo'`);
});
});
});
describe('addComponentToModule', () => {
beforeEach(() => {
mockFs( {
mockFs({
'1.ts': `
import {NgModule} from '@angular/core';
@ -208,7 +214,7 @@ class Module {}`
.then(change => change.apply())
.then(() => readFile('1.ts', 'utf-8'))
.then(content => {
expect(content).to.equal(
expect(content).toEqual(
'\n' +
'import {NgModule} from \'@angular/core\';\n' +
'import { MyClass } from \'MyImportPath\';\n' +
@ -218,7 +224,7 @@ class Module {}`
'})\n' +
'class Module {}'
);
})
});
});
it('works with array with declarations', () => {
@ -226,7 +232,7 @@ class Module {}`
.then(change => change.apply())
.then(() => readFile('2.ts', 'utf-8'))
.then(content => {
expect(content).to.equal(
expect(content).toEqual(
'\n' +
'import {NgModule} from \'@angular/core\';\n' +
'import { MyClass } from \'MyImportPath\';\n' +
@ -239,7 +245,7 @@ class Module {}`
'})\n' +
'class Module {}'
);
})
});
});
it('works without any declarations', () => {
@ -247,7 +253,7 @@ class Module {}`
.then(change => change.apply())
.then(() => readFile('3.ts', 'utf-8'))
.then(content => {
expect(content).to.equal(
expect(content).toEqual(
'\n' +
'import {NgModule} from \'@angular/core\';\n' +
'import { MyClass } from \'MyImportPath\';\n' +
@ -257,7 +263,7 @@ class Module {}`
'})\n' +
'class Module {}'
);
})
});
});
it('works without a declaration field', () => {
@ -265,7 +271,7 @@ class Module {}`
.then(change => change.apply())
.then(() => readFile('4.ts', 'utf-8'))
.then(content => {
expect(content).to.equal(
expect(content).toEqual(
'\n' +
'import {NgModule} from \'@angular/core\';\n' +
'import { MyClass } from \'MyImportPath\';\n' +
@ -277,11 +283,11 @@ class Module {}`
'})\n' +
'class Module {}'
);
})
});
});
});
/**
/**
* Gets node of kind kind from sourceFile
*/
function getNodesOfKind(kind: ts.SyntaxKind, sourceFile: string) {
@ -290,5 +296,5 @@ function getNodesOfKind(kind: ts.SyntaxKind, sourceFile: string) {
function getRootNode(sourceFile: string) {
return ts.createSourceFile(sourceFile, fs.readFileSync(sourceFile).toString(),
ts.ScriptTarget.ES6, true);
ts.ScriptTarget.ES6, true);
}

View File

@ -0,0 +1,271 @@
import * as ts from 'typescript';
import * as fs from 'fs';
import {Symbols} from '@angular/tsc-wrapped/src/symbols';
import {
isMetadataImportedSymbolReferenceExpression,
isMetadataModuleReferenceExpression
} from '@angular/tsc-wrapped';
import {Change, InsertChange, NoopChange, MultiChange} from './change';
import {findNodes} from './node';
import {insertImport} from './route-utils';
import {Observable} from 'rxjs/Observable';
import {ReplaySubject} from 'rxjs/ReplaySubject';
import 'rxjs/add/observable/empty';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/last';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/mergeMap';
import 'rxjs/add/operator/toArray';
import 'rxjs/add/operator/toPromise';
/**
* Get TS source file based on path.
* @param filePath
* @return source file of ts.SourceFile kind
*/
export function getSource(filePath: string): ts.SourceFile {
return ts.createSourceFile(filePath, fs.readFileSync(filePath).toString(),
ts.ScriptTarget.ES6, true);
}
/**
* Get all the nodes from a source, as an observable.
* @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): Observable<ts.Node> {
const subject = new ReplaySubject<ts.Node>();
let nodes: ts.Node[] = [sourceFile];
while(nodes.length > 0) {
const node = nodes.shift();
if (node) {
subject.next(node);
if (node.getChildCount(sourceFile) >= 0) {
nodes.unshift(...node.getChildren());
}
}
}
subject.complete();
return subject.asObservable();
}
/**
* 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 {
var 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`);
}
let lastItemPosition: number = lastItem ? lastItem.end : fallbackPos;
return new InsertChange(file, lastItemPosition, toInsert);
}
export function getContentOfKeyLiteral(source: ts.SourceFile, node: ts.Node): string {
if (node.kind == ts.SyntaxKind.Identifier) {
return (<ts.Identifier>node).text;
} else if (node.kind == ts.SyntaxKind.StringLiteral) {
try {
return JSON.parse(node.getFullText(source))
} catch (e) {
return null;
}
} else {
return null;
}
}
export function getDecoratorMetadata(source: ts.SourceFile, identifier: string,
module: string): Observable<ts.Node> {
const symbols = new Symbols(source as any);
return getSourceNodes(source)
.filter(node => {
return node.kind == ts.SyntaxKind.Decorator
&& (<ts.Decorator>node).expression.kind == ts.SyntaxKind.CallExpression;
})
.map(node => <ts.CallExpression>(<ts.Decorator>node).expression)
.filter(expr => {
if (expr.expression.kind == ts.SyntaxKind.Identifier) {
const id = <ts.Identifier>expr.expression;
const metaData = symbols.resolve(id.getFullText(source));
if (isMetadataImportedSymbolReferenceExpression(metaData)) {
return metaData.name == identifier && metaData.module == module;
}
} else if (expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression) {
// This covers foo.NgModule when importing * as foo.
const paExpr = <ts.PropertyAccessExpression>expr.expression;
// 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;
const moduleId = <ts.Identifier>paExpr.expression;
const moduleMetaData = symbols.resolve(moduleId.getFullText(source));
if (isMetadataModuleReferenceExpression(moduleMetaData)) {
return moduleMetaData.module == module && id.getFullText(source) == identifier;
}
}
return false;
})
.filter(expr => expr.arguments[0]
&& expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression)
.map(expr => <ts.ObjectLiteralExpression>expr.arguments[0]);
}
function _addSymbolToNgModuleMetadata(ngModulePath: string, metadataField: string,
symbolName: string, importPath: string) {
const source: ts.SourceFile = getSource(ngModulePath);
let metadata = getDecoratorMetadata(source, 'NgModule', '@angular/core');
// Find the decorator declaration.
return metadata
.toPromise()
.then((node: ts.ObjectLiteralExpression) => {
if (!node) {
return null;
}
// Get all the children property assignment of object literals.
return node.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.
.then((matchingProperties: ts.ObjectLiteralElement[]): any => {
if (!matchingProperties) {
return null;
}
if (matchingProperties.length == 0) {
return metadata.toPromise();
}
const assignment = <ts.PropertyAssignment>matchingProperties[0];
// If it's not an array, nothing we can do really.
if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) {
return null;
}
const arrLiteral = <ts.ArrayLiteralExpression>assignment.initializer;
if (arrLiteral.elements.length == 0) {
// Forward the property.
return arrLiteral;
}
return arrLiteral.elements;
})
.then((node: ts.Node) => {
if (!node) {
console.log('No app module found. Please add your new class to your component.');
return new NoopChange();
}
if (Array.isArray(node)) {
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.
let expr = <ts.ObjectLiteralExpression>node;
if (expr.properties.length == 0) {
position = expr.getEnd() - 1;
toInsert = ` ${metadataField}: [${symbolName}]\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.startsWith('\n')) {
toInsert = `,${text.match(/^\n(\r?)\s+/)[0]}${metadataField}: [${symbolName}]`;
} else {
toInsert = `, ${metadataField}: [${symbolName}]`;
}
}
} else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) {
// We found the field but it's empty. Insert it just before the `]`.
position--;
toInsert = `${symbolName}`;
} else {
// Get the indentation of the last element, if any.
const text = node.getFullText(source);
if (text.startsWith('\n')) {
toInsert = `,${text.match(/^\n(\r?)\s+/)[0]}${symbolName}`;
} else {
toInsert = `, ${symbolName}`;
}
}
const insert = new InsertChange(ngModulePath, position, toInsert);
const importInsert: Change = insertImport(ngModulePath, symbolName, importPath);
return new MultiChange([insert, importInsert]);
});
}
/**
* Custom function to insert a declaration (component, pipe, directive)
* into NgModule declarations. It also imports the component.
*/
export function addComponentToModule(modulePath: string, classifiedName: string,
importPath: string): Promise<Change> {
return _addSymbolToNgModuleMetadata(modulePath, 'declarations', classifiedName, importPath);
}
/**
* Custom function to insert a provider into NgModule. It also imports it.
*/
export function addProviderToModule(modulePath: string, classifiedName: string,
importPath: string): Promise<Change> {
return _addSymbolToNgModuleMetadata(modulePath, 'providers', classifiedName, importPath);
}

View File

@ -3,8 +3,8 @@
// This needs to be first so fs module can be mocked correctly.
let mockFs = require('mock-fs');
import {expect} from 'chai';
import {InsertChange, RemoveChange, ReplaceChange} from '../../addon/ng2/utilities/change';
import {it} from './spec-utils';
import {InsertChange, RemoveChange, ReplaceChange} from './change';
import fs = require('fs');
let path = require('path');
@ -38,11 +38,11 @@ describe('Change', () => {
.apply()
.then(() => readFile(sourceFile, 'utf8'))
.then(contents => {
expect(contents).to.equal('hello world!');
expect(contents).toEqual('hello world!');
});
});
it('fails for negative position', () => {
expect(() => new InsertChange(sourceFile, -6, ' world!')).to.throw(Error);
expect(() => new InsertChange(sourceFile, -6, ' world!')).toThrowError();
});
it('adds nothing in the source code if empty string is inserted', () => {
let changeInstance = new InsertChange(sourceFile, 6, '');
@ -50,7 +50,7 @@ describe('Change', () => {
.apply()
.then(() => readFile(sourceFile, 'utf8'))
.then(contents => {
expect(contents).to.equal('hello');
expect(contents).toEqual('hello');
});
});
});
@ -64,11 +64,11 @@ describe('Change', () => {
.apply()
.then(() => readFile(sourceFile, 'utf8'))
.then(contents => {
expect(contents).to.equal('import * from "./bar"');
expect(contents).toEqual('import * from "./bar"');
});
});
it('fails for negative position', () => {
expect(() => new RemoveChange(sourceFile, -6, ' world!')).to.throw(Error);
expect(() => new RemoveChange(sourceFile, -6, ' world!')).toThrow();
});
it('does not change the file if told to remove empty string', () => {
let changeInstance = new RemoveChange(sourceFile, 9, '');
@ -76,7 +76,7 @@ describe('Change', () => {
.apply()
.then(() => readFile(sourceFile, 'utf8'))
.then(contents => {
expect(contents).to.equal('import * as foo from "./bar"');
expect(contents).toEqual('import * as foo from "./bar"');
});
});
});
@ -89,12 +89,12 @@ describe('Change', () => {
.apply()
.then(() => readFile(sourceFile, 'utf8'))
.then(contents => {
expect(contents).to.equal('import { fooComponent } from "./bar"');
expect(contents).toEqual('import { fooComponent } from "./bar"');
});
});
it('fails for negative position', () => {
let sourceFile = path.join(sourcePath, 'remove-replace-file.txt');
expect(() => new ReplaceChange(sourceFile, -6, 'hello', ' world!')).to.throw(Error);
expect(() => new ReplaceChange(sourceFile, -6, 'hello', ' world!')).toThrow();
});
it('adds string to the position of an empty string', () => {
let sourceFile = path.join(sourcePath, 'replace-file.txt');
@ -103,7 +103,7 @@ describe('Change', () => {
.apply()
.then(() => readFile(sourceFile, 'utf8'))
.then(contents => {
expect(contents).to.equal('import { BarComponent, FooComponent } from "./baz"');
expect(contents).toEqual('import { BarComponent, FooComponent } from "./baz"');
});
});
it('removes the given string only if an empty string to add is given', () => {
@ -113,7 +113,7 @@ describe('Change', () => {
.apply()
.then(() => readFile(sourceFile, 'utf8'))
.then(contents => {
expect(contents).to.equal('import * from "./bar"');
expect(contents).toEqual('import * from "./bar"');
});
});
});

View File

@ -0,0 +1,152 @@
import fs = require('fs');
import denodeify = require('denodeify');
const readFile = (denodeify(fs.readFile) as (...args: any[]) => Promise<string>);
const writeFile = (denodeify(fs.writeFile) as (...args: any[]) => Promise<string>);
export interface Change {
apply(): 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: string = null;
apply() { return Promise.resolve(); }
}
/**
* An operation that mixes two or more changes, and merge them (in order).
* Can only apply to a single file. Use a ChangeManager to apply changes to multiple
* files.
*/
export class MultiChange implements Change {
private _path: string;
private _changes: Change[];
constructor(...changes: (Change[] | Change)[]) {
this._changes = [];
[].concat(...changes).forEach(change => this.appendChange(change));
}
appendChange(change: Change) {
// Validate that the path is the same for everyone of those.
if (this._path === undefined) {
this._path = change.path;
} else if (change.path !== this._path) {
throw new Error('Cannot apply a change to a different path.');
}
this._changes.push(change);
}
get description() {
return `Changes:\n ${this._changes.map(x => x.description).join('\n ')}`;
}
// Always apply as early as the highest change.
get order() { return Math.max(...this._changes.map(c => c.order)); }
get path() { return this._path; }
apply() {
return this._changes
.sort((a: Change, b: Change) => b.order - a.order)
.reduce((promise, change) => {
return promise.then(() => change.apply())
}, Promise.resolve());
}
}
/**
* Will add text to the source code.
*/
export class InsertChange implements Change {
order: number;
description: string;
constructor(public path: string, private pos: number, private 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(): Promise<any> {
return readFile(this.path, 'utf8').then(content => {
let prefix = content.substring(0, this.pos);
let suffix = content.substring(this.pos);
return writeFile(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(): Promise<any> {
return readFile(this.path, 'utf8').then(content => {
let prefix = content.substring(0, this.pos);
let suffix = content.substring(this.pos + this.toRemove.length);
// TODO: throw error if toRemove doesn't match removed string.
return writeFile(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(): Promise<any> {
return readFile(this.path, 'utf8').then(content => {
let prefix = content.substring(0, this.pos);
let suffix = content.substring(this.pos + this.oldText.length);
// TODO: throw error if oldText doesn't match removed string.
return writeFile(this.path, `${prefix}${this.newText}${suffix}`);
});
}
}

View File

@ -0,0 +1,3 @@
export * from './ast-utils';
export * from './change';
export * from './node';

View File

@ -0,0 +1,47 @@
import ts = require('typescript');
import {RemoveChange, Change} from './change';
/**
* 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: number = Infinity): ts.Node[] {
if (!node || max == 0) {
return [];
}
let 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;
}
export function removeAstNode(node: ts.Node): Change {
const source = node.getSourceFile();
return new RemoveChange(
source.path,
node.getStart(source),
node.getFullText(source)
);
}

View File

@ -1,14 +1,14 @@
import * as mockFs from 'mock-fs';
import * as fs from 'fs';
import { expect } from 'chai';
import * as nru from '../../addon/ng2/utilities/route-utils';
import * as ts from 'typescript';
import * as nru from './route-utils';
import * as path from 'path';
import { InsertChange, RemoveChange } from '../../addon/ng2/utilities/change';
import * as Promise from 'ember-cli/lib/ext/promise';
import { InsertChange, RemoveChange } from './change';
import denodeify = require('denodeify');
import * as _ from 'lodash';
import {it} from './spec-utils';
const readFile = (denodeify(fs.readFile) as (...args: any[]) => Promise<string>);
const readFile = Promise.denodeify(fs.readFile);
describe('route utils', () => {
describe('insertImport', () => {
@ -30,76 +30,76 @@ describe('route utils', () => {
let content = `'use strict'\n import {foo} from 'bar'\n import * as fz from 'fizz';`;
let editedFile = new InsertChange(sourceFile, 0, content);
return editedFile.apply()
.then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply())
.then(() => readFile(sourceFile, 'utf8'))
.then(newContent => {
expect(newContent).to.equal(content + `\nimport { Router } from '@angular/router';`);
});
.then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply())
.then(() => readFile(sourceFile, 'utf8'))
.then(newContent => {
expect(newContent).toEqual(content + `\nimport { Router } from '@angular/router';`);
});
});
it('does not insert if present', () => {
let content = `'use strict'\n import {Router} from '@angular/router'`;
let editedFile = new InsertChange(sourceFile, 0, content);
return editedFile.apply()
.then(() => nru.insertImport(sourceFile, 'Router', '@angular/router'))
.then(() => readFile(sourceFile, 'utf8'))
.then(newContent => {
expect(newContent).to.equal(content);
});
.then(() => nru.insertImport(sourceFile, 'Router', '@angular/router'))
.then(() => readFile(sourceFile, 'utf8'))
.then(newContent => {
expect(newContent).toEqual(content);
});
});
it('inserts into existing import clause if import file is already cited', () => {
let content = `'use strict'\n import { foo, bar } from 'fizz'`;
let editedFile = new InsertChange(sourceFile, 0, content);
return editedFile.apply()
.then(() => nru.insertImport(sourceFile, 'baz', 'fizz').apply())
.then(() => readFile(sourceFile, 'utf8'))
.then(newContent => {
expect(newContent).to.equal(`'use strict'\n import { foo, bar, baz } from 'fizz'`);
});
.then(() => nru.insertImport(sourceFile, 'baz', 'fizz').apply())
.then(() => readFile(sourceFile, 'utf8'))
.then(newContent => {
expect(newContent).toEqual(`'use strict'\n import { foo, bar, baz } from 'fizz'`);
});
});
it('understands * imports', () => {
let content = `\nimport * as myTest from 'tests' \n`;
let editedFile = new InsertChange(sourceFile, 0, content);
return editedFile.apply()
.then(() => nru.insertImport(sourceFile, 'Test', 'tests'))
.then(() => readFile(sourceFile, 'utf8'))
.then(newContent => {
expect(newContent).to.equal(content);
});
.then(() => nru.insertImport(sourceFile, 'Test', 'tests'))
.then(() => readFile(sourceFile, 'utf8'))
.then(newContent => {
expect(newContent).toEqual(content);
});
});
it('inserts after use-strict', () => {
let content = `'use strict';\n hello`;
let editedFile = new InsertChange(sourceFile, 0, content);
return editedFile.apply()
.then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply())
.then(() => readFile(sourceFile, 'utf8'))
.then(newContent => {
expect(newContent).to.equal(
`'use strict';\nimport { Router } from '@angular/router';\n hello`);
});
.then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply())
.then(() => readFile(sourceFile, 'utf8'))
.then(newContent => {
expect(newContent).toEqual(
`'use strict';\nimport { Router } from '@angular/router';\n hello`);
});
});
it('inserts inserts at beginning of file if no imports exist', () => {
return nru.insertImport(sourceFile, 'Router', '@angular/router').apply()
.then(() => readFile(sourceFile, 'utf8'))
.then(newContent => {
expect(newContent).to.equal(`import { Router } from '@angular/router';\n`);
});
.then(() => readFile(sourceFile, 'utf8'))
.then(newContent => {
expect(newContent).toEqual(`import { Router } from '@angular/router';\n`);
});
});
});
describe('bootstrapItem', () => {
const mainFile = 'tmp/main.ts';
const prefix = `import {bootstrap} from '@angular/platform-browser-dynamic'; \n` +
`import { AppComponent } from './app/';\n`;
`import { AppComponent } from './app/';\n`;
const routes = {'provideRouter': ['@angular/router'], 'routes': ['./routes', true]};
const toBootstrap = 'provideRouter(routes)';
const routerImport = `import routes from './routes';\n` +
`import { provideRouter } from '@angular/router'; \n`;
`import { provideRouter } from '@angular/router'; \n`;
beforeEach(() => {
let mockDrive = {
'tmp': {
'main.ts': `import {bootstrap} from '@angular/platform-browser-dynamic'; \n` +
`import { AppComponent } from './app/'; \n` +
'bootstrap(AppComponent);'
`import { AppComponent } from './app/'; \n` +
'bootstrap(AppComponent);'
}
};
mockFs(mockDrive);
@ -111,107 +111,107 @@ describe('route utils', () => {
it('adds a provideRouter import if not there already', () => {
return nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).to.equal(prefix + routerImport +
'bootstrap(AppComponent, [ provideRouter(routes) ]);');
});
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).toEqual(prefix + routerImport +
'bootstrap(AppComponent, [ provideRouter(routes) ]);');
});
});
it('does not add a provideRouter import if it exits already', () => {
xit('does not add a provideRouter import if it exits already', () => {
return nru.insertImport(mainFile, 'provideRouter', '@angular/router').apply()
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)));
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).to.equal(
`import routes from './routes';
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).toEqual(
`import routes from './routes';
import { provideRouter } from '@angular/router';
bootstrap(AppComponent, [ provideRouter(routes) ]);`);
});
});
});
it('does not duplicate import to route.ts ', () => {
xit('does not duplicate import to route.ts ', () => {
let editedFile = new InsertChange(mainFile, 100, `\nimport routes from './routes';`);
return editedFile
.apply()
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).to.equal(prefix + routerImport +
'bootstrap(AppComponent, [ provideRouter(routes) ]);');
});
.apply()
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).toEqual(prefix + routerImport +
'bootstrap(AppComponent, [ provideRouter(routes) ]);');
});
});
it('adds provideRouter to bootstrap if absent and no providers array', () => {
return nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).to.equal(prefix + routerImport +
'bootstrap(AppComponent, [ provideRouter(routes) ]);');
});
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).toEqual(prefix + routerImport +
'bootstrap(AppComponent, [ provideRouter(routes) ]);');
});
});
it('adds provideRouter to bootstrap if absent and empty providers array', () => {
let editFile = new InsertChange(mainFile, 124, ', []');
return editFile.apply()
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).to.equal(prefix + routerImport +
'bootstrap(AppComponent, [provideRouter(routes)]);');
});
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).toEqual(prefix + routerImport +
'bootstrap(AppComponent, [provideRouter(routes)]);');
});
});
it('adds provideRouter to bootstrap if absent and non-empty providers array', () => {
let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS ]');
return editedFile.apply()
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).to.equal(prefix + routerImport +
'bootstrap(AppComponent, [ HTTP_PROVIDERS, provideRouter(routes) ]);');
});
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).toEqual(prefix + routerImport +
'bootstrap(AppComponent, [ HTTP_PROVIDERS, provideRouter(routes) ]);');
});
});
it('does not add provideRouter to bootstrap if present', () => {
let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS, provideRouter(routes) ]');
return editedFile.apply()
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).to.equal(prefix + routerImport +
'bootstrap(AppComponent, [ HTTP_PROVIDERS, provideRouter(routes) ]);');
});
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).toEqual(prefix + routerImport +
'bootstrap(AppComponent, [ HTTP_PROVIDERS, provideRouter(routes) ]);');
});
});
it('inserts into the correct array', () => {
let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS, {provide: [BAR]}]');
return editedFile.apply()
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).to.equal(prefix + routerImport +
'bootstrap(AppComponent, [ HTTP_PROVIDERS, {provide: [BAR]}, provideRouter(routes)]);');
});
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).toEqual(prefix + routerImport +
'bootstrap(AppComponent, [ HTTP_PROVIDERS, {provide: [BAR]}, provideRouter(routes)]);');
});
});
it('throws an error if there is no or multiple bootstrap expressions', () => {
let editedFile = new InsertChange(mainFile, 126, '\n bootstrap(moreStuff);');
return editedFile.apply()
.then(() => nru.bootstrapItem(mainFile, routes, toBootstrap))
.catch(e =>
expect(e.message).to.equal('Did not bootstrap provideRouter in' +
' tmp/main.ts because of multiple or no bootstrap calls')
);
.then(() => nru.bootstrapItem(mainFile, routes, toBootstrap))
.catch(e =>
expect(e.message).toEqual('Did not bootstrap provideRouter in' +
' tmp/main.ts because of multiple or no bootstrap calls')
);
});
it('configures correctly if bootstrap or provide router is not at top level', () => {
let editedFile = new InsertChange(mainFile, 126, '\n if(e){bootstrap, provideRouter});');
return editedFile.apply()
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).to.equal(prefix + routerImport +
'bootstrap(AppComponent, [ provideRouter(routes) ]);\n if(e){bootstrap, provideRouter});');
});
.then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)))
.then(() => readFile(mainFile, 'utf8'))
.then(content => {
expect(content).toEqual(prefix + routerImport +
'bootstrap(AppComponent, [ provideRouter(routes) ]);\n if(e){bootstrap, provideRouter});');
});
});
});
describe('addPathToRoutes', () => {
const routesFile = 'src/routes.ts';
var options = {dir: 'src/app', appRoot: 'src/app', routesFile: routesFile,
component: 'NewRouteComponent', dasherizedName: 'new-route'};
component: 'NewRouteComponent', dasherizedName: 'new-route'};
const nestedRoutes = `\n { path: 'home', component: HomeComponent,
children: [
{ path: 'about', component: AboutComponent,
@ -236,20 +236,20 @@ describe('route utils', () => {
it('adds import to new route component if absent', () => {
return nru.applyChanges(nru.addPathToRoutes(routesFile, _.merge({route: 'new-route'}, options)))
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
expect(content).to.equal(
`import { NewRouteComponent } from './app/new-route/new-route.component';
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
expect(content).toEqual(
`import { NewRouteComponent } from './app/new-route/new-route.component';
export default [\n { path: 'new-route', component: NewRouteComponent }\n];`);
});
});
});
it('throws error if multiple export defaults exist', () => {
let editedFile = new InsertChange(routesFile, 20, 'export default {}');
return editedFile.apply().then(() => {
return nru.addPathToRoutes(routesFile, _.merge({route: 'new-route'}, options));
}).catch(e => {
expect(e.message).to.equal('Did not insert path in routes.ts because '
+ `there were multiple or no 'export default' statements`);
expect(e.message).toEqual('Did not insert path in routes.ts because '
+ `there were multiple or no 'export default' statements`);
});
});
it('throws error if no export defaults exists', () => {
@ -257,28 +257,28 @@ export default [\n { path: 'new-route', component: NewRouteComponent }\n];`);
return editedFile.apply().then(() => {
return nru.addPathToRoutes(routesFile, _.merge({route: 'new-route'}, options));
}).catch(e => {
expect(e.message).to.equal('Did not insert path in routes.ts because '
+ `there were multiple or no 'export default' statements`);
expect(e.message).toEqual('Did not insert path in routes.ts because '
+ `there were multiple or no 'export default' statements`);
});
});
it('treats positional params correctly', () => {
let editedFile = new InsertChange(routesFile, 16,
`\n { path: 'home', component: HomeComponent }\n`);
`\n { path: 'home', component: HomeComponent }\n`);
return editedFile.apply().then(() => {
options.dasherizedName = 'about';
options.component = 'AboutComponent';
return nru.applyChanges(
nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/:id'}, options))); })
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
expect(content).to.equal(
`import { AboutComponent } from './app/home/about/about.component';` +
`\nexport default [\n` +
` { path: 'home', component: HomeComponent,\n` +
` children: [\n` +
` { path: 'about/:id', component: AboutComponent } ` +
`\n ]\n }\n];`);
});
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
expect(content).toEqual(
`import { AboutComponent } from './app/home/about/about.component';` +
`\nexport default [\n` +
` { path: 'home', component: HomeComponent,\n` +
` children: [\n` +
` { path: 'about/:id', component: AboutComponent } ` +
`\n ]\n }\n];`);
});
});
it('inserts under parent, mid', () => {
let editedFile = new InsertChange(routesFile, 16, nestedRoutes);
@ -287,9 +287,9 @@ export default [\n { path: 'new-route', component: NewRouteComponent }\n];`);
options.component = 'DetailsComponent';
return nru.applyChanges(
nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/details'}, options))); })
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
let expected = `import { DetailsComponent } from './app/home/about/details/details.component';
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
let expected = `import { DetailsComponent } from './app/home/about/details/details.component';
export default [
{ path: 'home', component: HomeComponent,
children: [
@ -301,8 +301,8 @@ export default [
}
]
}\n];`;
expect(content).to.equal(expected);
});
expect(content).toEqual(expected);
});
});
it('inserts under parent, deep', () => {
let editedFile = new InsertChange(routesFile, 16, nestedRoutes);
@ -311,9 +311,9 @@ export default [
options.component = 'SectionsComponent';
return nru.applyChanges(
nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/more/sections'}, options))); })
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
let expected = `import { SectionsComponent } from './app/home/about/more/sections/sections.component';
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
let expected = `import { SectionsComponent } from './app/home/about/more/sections/sections.component';
export default [
{ path: 'home', component: HomeComponent,
children: [
@ -329,8 +329,8 @@ export default [
]
}
];`;
expect(content).to.equal(expected);
});
expect(content).toEqual(expected);
});
});
it('works well with multiple routes in a level', () => {
let paths = `\n { path: 'main', component: MainComponent }
@ -345,9 +345,9 @@ export default [
options.component = 'AboutComponent_1';
return nru.applyChanges(
nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/:id'}, options))); })
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
expect(content).to.equal(`import { AboutComponent_1 } from './app/home/about/about.component';
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
expect(content).toEqual(`import { AboutComponent_1 } from './app/home/about/about.component';
export default [
{ path: 'main', component: MainComponent }
{ path: 'home', component: HomeComponent,
@ -357,17 +357,17 @@ export default [
]
}
];`
);
});
);
});
});
it('throws error if repeating child, shallow', () => {
let editedFile = new InsertChange(routesFile, 16, nestedRoutes);
return editedFile.apply().then(() => {
options.dasherizedName = 'home';
options.component = 'HomeComponent';
return nru.addPathToRoutes(routesFile, _.merge({route: '/home'}, options));
return nru.addPathToRoutes(routesFile, _.merge({route: '/home'}, options));
}).catch(e => {
expect(e.message).to.equal('Route was not added since it is a duplicate');
expect(e.message).toEqual('Route was not added since it is a duplicate');
});
});
it('throws error if repeating child, mid', () => {
@ -375,9 +375,9 @@ export default [
return editedFile.apply().then(() => {
options.dasherizedName = 'about';
options.component = 'AboutComponent';
return nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/'}, options));
return nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/'}, options));
}).catch(e => {
expect(e.message).to.equal('Route was not added since it is a duplicate');
expect(e.message).toEqual('Route was not added since it is a duplicate');
});
});
it('throws error if repeating child, deep', () => {
@ -385,9 +385,9 @@ export default [
return editedFile.apply().then(() => {
options.dasherizedName = 'more';
options.component = 'MoreComponent';
return nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/more'}, options));
return nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/more'}, options));
}).catch(e => {
expect(e.message).to.equal('Route was not added since it is a duplicate');
expect(e.message).toEqual('Route was not added since it is a duplicate');
});
});
it('does not report false repeat', () => {
@ -397,9 +397,9 @@ export default [
options.component = 'MoreComponent';
return nru.applyChanges(nru.addPathToRoutes(routesFile, _.merge({route: 'more'}, options)));
})
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
let expected = `import { MoreComponent } from './app/more/more.component';
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
let expected = `import { MoreComponent } from './app/more/more.component';
export default [
{ path: 'more', component: MoreComponent },
{ path: 'home', component: HomeComponent,
@ -411,8 +411,8 @@ export default [
}
]
}\n];`;
expect(content).to.equal(expected);
});
expect(content).toEqual(expected);
});
});
it('does not report false repeat: multiple paths on a level', () => {
@ -431,8 +431,9 @@ export default [
options.dasherizedName = 'trap-queen';
options.component = 'TrapQueenComponent';
return nru.applyChanges(
nru.addPathToRoutes(routesFile, _.merge({route: 'home/trap-queen'}, options))); })
.then(() => readFile(routesFile, 'utf8')
nru.addPathToRoutes(routesFile, _.merge({route: 'home/trap-queen'}, options)));
})
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
let expected = `import { TrapQueenComponent } from './app/home/trap-queen/trap-queen.component';
export default [
@ -446,25 +447,25 @@ export default [
}
]
},\n { path: 'trap-queen', component: TrapQueenComponent}\n];`;
expect(content).to.equal(expected);
expect(content).toEqual(expected);
});
});
it('resolves imports correctly', () => {
let editedFile = new InsertChange(routesFile, 16,
`\n { path: 'home', component: HomeComponent }\n`);
`\n { path: 'home', component: HomeComponent }\n`);
return editedFile.apply().then(() => {
let editedFile = new InsertChange(routesFile, 0,
`import { HomeComponent } from './app/home/home.component';\n`);
`import { HomeComponent } from './app/home/home.component';\n`);
return editedFile.apply();
})
.then(() => {
options.dasherizedName = 'home';
options.component = 'HomeComponent';
return nru.applyChanges(
nru.addPathToRoutes(routesFile, _.merge({route: 'home/home'}, options))); })
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
let expected = `import { HomeComponent } from './app/home/home.component';
.then(() => {
options.dasherizedName = 'home';
options.component = 'HomeComponent';
return nru.applyChanges(
nru.addPathToRoutes(routesFile, _.merge({route: 'home/home'}, options))); })
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
let expected = `import { HomeComponent } from './app/home/home.component';
import { HomeComponent as HomeComponent_1 } from './app/home/home/home.component';
export default [
{ path: 'home', component: HomeComponent,
@ -473,12 +474,12 @@ export default [
]
}
];`;
expect(content).to.equal(expected);
});
expect(content).toEqual(expected);
});
});
it('throws error if components collide and there is repitition', () => {
let editedFile = new InsertChange(routesFile, 16,
`\n { path: 'about', component: AboutComponent,
`\n { path: 'about', component: AboutComponent,
children: [
{ path: 'details/:id', component: DetailsComponent_1 },
{ path: 'details', component: DetailsComponent }
@ -486,7 +487,7 @@ export default [
}`);
return editedFile.apply().then(() => {
let editedFile = new InsertChange(routesFile, 0,
`import { AboutComponent } from './app/about/about.component';
`import { AboutComponent } from './app/about/about.component';
import { DetailsComponent } from './app/about/details/details.component';
import { DetailsComponent as DetailsComponent_1 } from './app/about/description/details.component;\n`);
return editedFile.apply();
@ -494,7 +495,7 @@ import { DetailsComponent as DetailsComponent_1 } from './app/about/description/
options.dasherizedName = 'details';
options.component = 'DetailsComponent';
expect(() => nru.addPathToRoutes(routesFile, _.merge({route: 'about/details'}, options)))
.to.throw(Error);
.toThrowError();
});
});
@ -503,11 +504,11 @@ import { DetailsComponent as DetailsComponent_1 } from './app/about/description/
let editedFile = new InsertChange(routesFile, 16, path);
return editedFile.apply().then(() => {
let toInsert = {'home': ['canActivate', '[ MyGuard ]'] };
return nru.applyChanges(nru.addItemsToRouteProperties(routesFile, toInsert)); })
return nru.applyChanges(nru.addItemsToRouteProperties(routesFile, toInsert));
})
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
expect(content).to.equal(
`export default [
expect(content).toEqual(`export default [
{ path: 'home', component: HomeComponent, canActivate: [ MyGuard ] }
];`
);
@ -521,16 +522,16 @@ import { DetailsComponent as DetailsComponent_1 } from './app/about/description/
options.component = 'MoreComponent';
return nru.applyChanges(
nru.addPathToRoutes(routesFile, _.merge({route: 'home/more'}, options))); })
.then(() => {
return nru.applyChanges(nru.addItemsToRouteProperties(routesFile,
{ 'home/more': ['canDeactivate', '[ MyGuard ]'] })); })
.then(() => {
return nru.applyChanges(nru.addItemsToRouteProperties(
routesFile, { 'home/more': ['useAsDefault', 'true'] })); })
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
expect(content).to.equal(
`import { MoreComponent } from './app/home/more/more.component';
.then(() => {
return nru.applyChanges(nru.addItemsToRouteProperties(routesFile,
{ 'home/more': ['canDeactivate', '[ MyGuard ]'] })); })
.then(() => {
return nru.applyChanges(nru.addItemsToRouteProperties(
routesFile, { 'home/more': ['useAsDefault', 'true'] })); })
.then(() => readFile(routesFile, 'utf8'))
.then(content => {
expect(content).toEqual(
`import { MoreComponent } from './app/home/more/more.component';
export default [
{ path: 'home', component: HomeComponent,
children: [
@ -538,8 +539,8 @@ export default [
]
}
];`
);
});
);
});
});
});
@ -565,37 +566,37 @@ export default [
it('accepts component name without \'component\' suffix: resolveComponentPath', () => {
let fileName = nru.resolveComponentPath(projectRoot, 'src/app', 'about');
expect(fileName).to.equal(componentFile);
expect(fileName).toEqual(componentFile);
});
it('accepts component name with \'component\' suffix: resolveComponentPath', () => {
let fileName = nru.resolveComponentPath(projectRoot, 'src/app', 'about.component');
expect(fileName).to.equal(componentFile);
expect(fileName).toEqual(componentFile);
});
it('accepts path absolute from project root: resolveComponentPath', () => {
let fileName = nru.resolveComponentPath(projectRoot, '', `${path.sep}about`);
expect(fileName).to.equal(componentFile);
expect(fileName).toEqual(componentFile);
});
it('accept component with directory name: resolveComponentPath', () => {
let fileName = nru.resolveComponentPath(projectRoot, 'src/app', 'about/about.component');
expect(fileName).to.equal(componentFile);
expect(fileName).toEqual(componentFile);
});
it('finds component name: confirmComponentExport', () => {
let exportExists = nru.confirmComponentExport(componentFile, 'AboutComponent');
expect(exportExists).to.be.truthy;
expect(exportExists).toBeTruthy();
});
it('finds component in the presence of decorators: confirmComponentExport', () => {
let editedFile = new InsertChange(componentFile, 0, '@Component{}\n');
return editedFile.apply().then(() => {
let exportExists = nru.confirmComponentExport(componentFile, 'AboutComponent');
expect(exportExists).to.be.truthy;
expect(exportExists).toBeTruthy();
});
});
it('report absence of component name: confirmComponentExport', () => {
let editedFile = new RemoveChange(componentFile, 21, 'onent');
return editedFile.apply().then(() => {
let exportExists = nru.confirmComponentExport(componentFile, 'AboutComponent');
expect(exportExists).to.not.be.truthy;
expect(exportExists).not.toBeTruthy();
});
});
});

View File

@ -0,0 +1,529 @@
import * as ts from 'typescript';
import * as fs from 'fs';
import * as path from 'path';
import {Change, InsertChange, NoopChange} from './change';
import {findNodes} from './node';
import {insertAfterLastOccurrence} from './ast-utils';
/**
* Adds imports to mainFile and adds toBootstrap to the array of providers
* in bootstrap, if not present
* @param mainFile main.ts
* @param imports Object { importedClass: ['path/to/import/from', defaultStyleImport?] }
* @param toBootstrap
*/
export function bootstrapItem(mainFile: string, imports: {[key: string]: (string | boolean)[]}, toBootstrap: string ) {
let changes = Object.keys(imports).map(importedClass => {
var defaultStyleImport = imports[importedClass].length === 2 && !!imports[importedClass][1];
return insertImport(
mainFile,
importedClass,
imports[importedClass][0].toString(),
defaultStyleImport
);
});
let rootNode = getRootNode(mainFile);
// get ExpressionStatements from the top level syntaxList of the sourceFile
let bootstrapNodes = rootNode.getChildAt(0).getChildren().filter(node => {
// get bootstrap expressions
return node.kind === ts.SyntaxKind.ExpressionStatement &&
(node.getChildAt(0).getChildAt(0) as ts.Identifier).text.toLowerCase() === 'bootstrap';
});
if (bootstrapNodes.length !== 1) {
throw new Error(`Did not bootstrap provideRouter in ${mainFile}` +
' because of multiple or no bootstrap calls');
}
let bootstrapNode = bootstrapNodes[0].getChildAt(0);
let isBootstraped = findNodes(bootstrapNode, ts.SyntaxKind.SyntaxList) // get bootstrapped items
.reduce((a, b) => a.concat(b.getChildren().map(n => n.getText())), [])
.filter(n => n !== ',')
.indexOf(toBootstrap) !== -1;
if (isBootstraped) {
return changes;
}
// if bracket exitst already, add configuration template,
// otherwise, insert into bootstrap parens
var fallBackPos: number, configurePathsTemplate: string, separator: string;
var syntaxListNodes: any;
let bootstrapProviders = bootstrapNode.getChildAt(2).getChildAt(2); // array of providers
if ( bootstrapProviders ) {
syntaxListNodes = bootstrapProviders.getChildAt(1).getChildren();
fallBackPos = bootstrapProviders.getChildAt(2).pos; // closeBracketLiteral
separator = syntaxListNodes.length === 0 ? '' : ', ';
configurePathsTemplate = `${separator}${toBootstrap}`;
} else {
fallBackPos = bootstrapNode.getChildAt(3).pos; // closeParenLiteral
syntaxListNodes = bootstrapNode.getChildAt(2).getChildren();
configurePathsTemplate = `, [ ${toBootstrap} ]`;
}
changes.push(insertAfterLastOccurrence(syntaxListNodes, configurePathsTemplate,
mainFile, fallBackPos));
return changes;
}
/**
* 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(fileToEdit: string, symbolName: string,
fileName: string, isDefault = false): Change {
let rootNode = getRootNode(fileToEdit);
let allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
// get nodes that map to import statements from the file fileName
let relevantImports = allImports.filter(node => {
// StringLiteral of the ImportDeclaration is the import file (fileName in this case).
let importFiles = node.getChildren().filter(child => child.kind === ts.SyntaxKind.StringLiteral)
.map(n => (<ts.StringLiteralTypeNode>n).text);
return importFiles.filter(file => file === fileName).length === 1;
});
if (relevantImports.length > 0) {
var importsAsterisk = false;
// imports from import file
let 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;
}
let importTextNodes = imports.filter(n => (<ts.Identifier>n).text === symbolName);
// insert import if it's not there
if (importTextNodes.length === 0) {
let 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
let 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;
}
let open = isDefault ? '' : '{ ';
let close = isDefault ? '' : ' }';
// if there are no imports or 'use strict' statement, insert import at beginning of file
let insertAtBeginning = allImports.length === 0 && useStrict.length === 0;
let separator = insertAtBeginning ? '' : ';\n';
let toInsert = `${separator}import ${open}${symbolName}${close}` +
` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`;
return insertAfterLastOccurrence(allImports, toInsert, fileToEdit, fallbackPos, ts.SyntaxKind.StringLiteral);
};
/**
* Inserts a path to the new route into src/routes.ts if it doesn't exist
* @param routesFile
* @param pathOptions
* @return Change[]
* @throws Error if routesFile has multiple export default or none.
*/
export function addPathToRoutes(routesFile: string, pathOptions: any): Change[] {
let route = pathOptions.route.split('/')
.filter((n: string) => n !== '').join('/'); // change say `/about/:id/` to `about/:id`
let isDefault = pathOptions.isDefault ? ', useAsDefault: true' : '';
let outlet = pathOptions.outlet ? `, outlet: '${pathOptions.outlet}'` : '';
// create route path and resolve component import
let positionalRoutes = /\/:[^/]*/g;
let routePath = route.replace(positionalRoutes, '');
routePath = `./app/${routePath}/${pathOptions.dasherizedName}.component`;
let originalComponent = pathOptions.component;
pathOptions.component = resolveImportName(pathOptions.component, routePath, pathOptions.routesFile);
var content = `{ path: '${route}', component: ${pathOptions.component}${isDefault}${outlet} }`;
let rootNode = getRootNode(routesFile);
let routesNode = rootNode.getChildAt(0).getChildren().filter(n => {
// get export statement
return n.kind === ts.SyntaxKind.ExportAssignment &&
n.getFullText().indexOf('export default') !== -1;
});
if (routesNode.length !== 1) {
throw new Error('Did not insert path in routes.ts because ' +
`there were multiple or no 'export default' statements`);
}
var pos = routesNode[0].getChildAt(2).getChildAt(0).end; // openBracketLiteral
// all routes in export route array
let routesArray = routesNode[0].getChildAt(2).getChildAt(1)
.getChildren()
.filter(n => n.kind === ts.SyntaxKind.ObjectLiteralExpression);
if (pathExists(routesArray, route, pathOptions.component)) {
// don't duplicate routes
throw new Error('Route was not added since it is a duplicate');
}
var isChild = false;
// get parent to insert under
let parent: ts.Node;
if (pathOptions.parent) {
// append '_' to route to find the actual parent (not parent of the parent)
parent = getParent(routesArray, `${pathOptions.parent}/_`);
if (!parent) {
throw new Error(`You specified parent '${pathOptions.parent}'' which was not found in routes.ts`);
}
if (route.indexOf(pathOptions.parent) === 0) {
route = route.substring(pathOptions.parent.length);
}
} else {
parent = getParent(routesArray, route);
}
if (parent) {
let childrenInfo = addChildPath(parent, pathOptions, route);
if (!childrenInfo) {
// path exists already
throw new Error('Route was not added since it is a duplicate');
}
content = childrenInfo.newContent;
pos = childrenInfo.pos;
isChild = true;
}
let isFirstElement = routesArray.length === 0;
if (!isChild) {
let separator = isFirstElement ? '\n' : ',';
content = `\n ${content}${separator}`;
}
let changes: Change[] = [new InsertChange(routesFile, pos, content)];
let component = originalComponent === pathOptions.component ? originalComponent :
`${originalComponent} as ${pathOptions.component}`;
routePath = routePath.replace(/\\/, '/'); // correction in windows
changes.push(insertImport(routesFile, component, routePath));
return changes;
}
/**
* Add more properties to the route object in routes.ts
* @param routesFile routes.ts
* @param routes Object {route: [key, value]}
*/
export function addItemsToRouteProperties(routesFile: string, routes: {[key: string]: string[]}) {
let rootNode = getRootNode(routesFile);
let routesNode = rootNode.getChildAt(0).getChildren().filter(n => {
// get export statement
return n.kind === ts.SyntaxKind.ExportAssignment &&
n.getFullText().indexOf('export default') !== -1;
});
if (routesNode.length !== 1) {
throw new Error('Did not insert path in routes.ts because ' +
`there were multiple or no 'export default' statements`);
}
let routesArray = routesNode[0].getChildAt(2).getChildAt(1)
.getChildren()
.filter(n => n.kind === ts.SyntaxKind.ObjectLiteralExpression);
let changes: Change[] = Object.keys(routes).reduce((result, route) => {
// let route = routes[guardName][0];
let itemKey = routes[route][0];
let itemValue = routes[route][1];
let currRouteNode = getParent(routesArray, `${route}/_`);
if (!currRouteNode) {
throw new Error(`Could not find '${route}' in routes.ts`);
}
let fallBackPos = findNodes(currRouteNode, ts.SyntaxKind.CloseBraceToken).pop().pos;
let pathPropertiesNodes = currRouteNode.getChildAt(1).getChildren()
.filter(n => n.kind === ts.SyntaxKind.PropertyAssignment);
return result.concat([insertAfterLastOccurrence(pathPropertiesNodes,
`, ${itemKey}: ${itemValue}`, routesFile, fallBackPos)]);
}, []);
return changes;
}
/**
* Verifies that a component file exports a class of the component
* @param file
* @param componentName
* @return whether file exports componentName
*/
export function confirmComponentExport (file: string, componentName: string): boolean {
const rootNode = getRootNode(file);
let exportNodes = rootNode.getChildAt(0).getChildren().filter(n => {
return n.kind === ts.SyntaxKind.ClassDeclaration &&
(n.getChildren().filter((p: ts.Identifier) => p.text === componentName).length !== 0);
});
return exportNodes.length > 0;
}
/**
* Ensures there is no collision between import names. If a collision occurs, resolve by adding
* underscore number to the name
* @param importName
* @param importPath path to import component from
* @param fileName (file to add import to)
* @return resolved importName
*/
function resolveImportName (importName: string, importPath: string, fileName: string): string {
const rootNode = getRootNode(fileName);
// get all the import names
let importNodes = rootNode.getChildAt(0).getChildren()
.filter(n => n.kind === ts.SyntaxKind.ImportDeclaration);
// check if imported file is same as current one before updating component name
let importNames = importNodes
.reduce((a, b) => {
let importFrom = findNodes(b, ts.SyntaxKind.StringLiteral); // there's only one
if ((importFrom.pop() as ts.StringLiteral).text !== importPath) {
// importing from different file, add to imported components to inspect
// if only one identifier { FooComponent }, if two { FooComponent as FooComponent_1 }
// choose last element of identifier array in both cases
return a.concat([findNodes(b, ts.SyntaxKind.Identifier).pop()]);
}
return a;
}, [])
.map(n => n.text);
const index = importNames.indexOf(importName);
if (index === -1) {
return importName;
}
const baseName = importNames[index].split('_')[0];
var newName = baseName;
var resolutionNumber = 1;
while (importNames.indexOf(newName) !== -1) {
newName = `${baseName}_${resolutionNumber}`;
resolutionNumber++;
}
return newName;
}
/**
* Resolve a path to a component file. If the path begins with path.sep, it is treated to be
* absolute from the app/ directory. Otherwise, it is relative to currDir
* @param projectRoot
* @param currentDir
* @param filePath componentName or path to componentName
* @return component file name
* @throw Error if component file referenced by path is not found
*/
export function resolveComponentPath(projectRoot: string, currentDir: string, filePath: string) {
let parsedPath = path.parse(filePath);
let componentName = parsedPath.base.split('.')[0];
let componentDir = path.parse(parsedPath.dir).base;
// correction for a case where path is /**/componentName/componentName(.component.ts)
if ( componentName === componentDir) {
filePath = parsedPath.dir;
}
if (parsedPath.dir === '') {
// only component file name is given
filePath = componentName;
}
var directory = filePath[0] === path.sep ?
path.resolve(path.join(projectRoot, 'src', 'app', filePath)) : path.resolve(currentDir, filePath);
if (!fs.existsSync(directory)) {
throw new Error(`path '${filePath}' must be relative to current directory` +
` or absolute from project root`);
}
if (directory.indexOf('src' + path.sep + 'app') === -1) {
throw new Error('Route must be within app');
}
let componentFile = path.join(directory, `${componentName}.component.ts`);
if (!fs.existsSync(componentFile)) {
throw new Error(`could not find component file referenced by ${filePath}`);
}
return componentFile;
}
/**
* Sort changes in decreasing order and apply them.
* @param changes
* @return Promise
*/
export function applyChanges(changes: Change[]): Promise<void> {
return changes
.filter(change => !!change)
.sort((curr, next) => next.order - curr.order)
.reduce((newChange, change) => newChange.then(() => change.apply()), Promise.resolve());
}
/**
* Helper for addPathToRoutes. Adds child array to the appropriate position in the routes.ts file
* @return Object (pos, newContent)
*/
function addChildPath (parentObject: ts.Node, pathOptions: any, route: string) {
if (!parentObject) {
return;
}
var pos: number;
var newContent: string;
// get object with 'children' property
let childrenNode = parentObject.getChildAt(1).getChildren()
.filter(n => n.kind === ts.SyntaxKind.PropertyAssignment
&& ((n as ts.PropertyAssignment).name as ts.Identifier).text === 'children');
// find number of spaces to pad nested paths
let nestingLevel = 1; // for indenting route object in the `children` array
let n = parentObject;
while (n.parent) {
if (n.kind === ts.SyntaxKind.ObjectLiteralExpression
|| n.kind === ts.SyntaxKind.ArrayLiteralExpression) {
nestingLevel ++;
}
n = n.parent;
}
// strip parent route
let parentRoute = (parentObject.getChildAt(1).getChildAt(0).getChildAt(2) as ts.Identifier).text;
let childRoute = route.substring(route.indexOf(parentRoute) + parentRoute.length + 1);
let isDefault = pathOptions.isDefault ? ', useAsDefault: true' : '';
let outlet = pathOptions.outlet ? `, outlet: '${pathOptions.outlet}'` : '';
let content = `{ path: '${childRoute}', component: ${pathOptions.component}` +
`${isDefault}${outlet} }`;
let spaces = Array(2 * nestingLevel + 1).join(' ');
if (childrenNode.length !== 0) {
// add to beginning of children array
pos = childrenNode[0].getChildAt(2).getChildAt(1).pos; // open bracket
newContent = `\n${spaces}${content}, `;
} else {
// no children array, add one
pos = parentObject.getChildAt(2).pos; // close brace
newContent = `,\n${spaces.substring(2)}children: [\n${spaces}${content} ` +
`\n${spaces.substring(2)}]\n${spaces.substring(5)}`;
}
return {newContent: newContent, pos: pos};
}
/**
* Helper for addPathToRoutes.
* @return parentNode which contains the children array to add a new path to or
* undefined if none or the entire route was matched.
*/
function getParent(routesArray: ts.Node[], route: string, parent?: ts.Node): ts.Node {
if (routesArray.length === 0 && !parent) {
return; // no children array and no parent found
}
if (route.length === 0) {
return; // route has been completely matched
}
var splitRoute = route.split('/');
// don't treat positional parameters separately
if (splitRoute.length > 1 && splitRoute[1].indexOf(':') !== -1) {
let actualRoute = splitRoute.shift();
splitRoute[0] = `${actualRoute}/${splitRoute[0]}`;
}
let potentialParents: ts.Node[] = routesArray // route nodes with same path as current route
.filter(n => getValueForKey(n, 'path') === splitRoute[0]);
if (potentialParents.length !== 0) {
splitRoute.shift(); // matched current parent, move on
route = splitRoute.join('/');
}
// get all children paths
let newRouteArray = getChildrenArray(routesArray);
if (route && parent && potentialParents.length === 0) {
return parent; // final route is not matched. assign parent from here
}
parent = potentialParents.sort((a, b) => a.pos - b.pos).shift();
return getParent(newRouteArray, route, parent);
}
/**
* Helper for addPathToRoutes.
* @return whether path with same route and component exists
*/
function pathExists(routesArray: ts.Node[], route: string, component: string, fullRoute?: string): boolean {
if (routesArray.length === 0) {
return false;
}
fullRoute = fullRoute ? fullRoute : route;
var sameRoute = false;
var splitRoute = route.split('/');
// don't treat positional parameters separately
if (splitRoute.length > 1 && splitRoute[1].indexOf(':') !== -1) {
let actualRoute = splitRoute.shift();
splitRoute[0] = `${actualRoute}/${splitRoute[0]}`;
}
let repeatedRoutes: ts.Node[] = routesArray.filter(n => {
let currentRoute = getValueForKey(n, 'path');
let sameComponent = getValueForKey(n, 'component') === component;
sameRoute = currentRoute === splitRoute[0];
// Confirm that it's parents are the same
if (sameRoute && sameComponent) {
var path = currentRoute;
let objExp = n.parent;
while (objExp) {
if (objExp.kind === ts.SyntaxKind.ObjectLiteralExpression) {
let currentParentPath = getValueForKey(objExp, 'path');
path = currentParentPath ? `${currentParentPath}/${path}` : path;
}
objExp = objExp.parent;
}
return path === fullRoute;
}
return false;
});
if (sameRoute) {
splitRoute.shift(); // matched current parent, move on
route = splitRoute.join('/');
}
if (repeatedRoutes.length !== 0) {
return true; // new path will be repeating if inserted. report that path already exists
}
// all children paths
let newRouteArray = getChildrenArray(routesArray);
return pathExists(newRouteArray, route, component, fullRoute);
}
/**
* Helper for getParent and pathExists
* @return array with all nodes holding children array under routes
* in routesArray
*/
function getChildrenArray(routesArray: ts.Node[]): ts.Node[] {
return routesArray.reduce((allRoutes, currRoute) => allRoutes.concat(
currRoute.getChildAt(1).getChildren()
.filter(n => n.kind === ts.SyntaxKind.PropertyAssignment
&& ((n as ts.PropertyAssignment).name as ts.Identifier).text === 'children')
.map(n => n.getChildAt(2).getChildAt(1)) // syntaxList containing chilren paths
.reduce((childrenArray, currChild) => childrenArray.concat(currChild.getChildren()
.filter(p => p.kind === ts.SyntaxKind.ObjectLiteralExpression)
), [])
), []);
}
/**
* Helper method to get the path text or component
* @param objectLiteralNode
* @param key 'path' or 'component'
*/
function getValueForKey(objectLiteralNode: ts.Node, key: string) {
let currentNode = key === 'component' ? objectLiteralNode.getChildAt(1).getChildAt(2) :
objectLiteralNode.getChildAt(1).getChildAt(0);
return currentNode
&& currentNode.getChildAt(0)
&& (currentNode.getChildAt(0) as ts.Identifier).text === key
&& currentNode.getChildAt(2)
&& (currentNode.getChildAt(2) as ts.Identifier).text;
}
/**
* Helper method to get AST from file
* @param file
*/
function getRootNode(file: string) {
return ts.createSourceFile(file, fs.readFileSync(file).toString(), ts.ScriptTarget.ES6, true);
}

View File

@ -0,0 +1,26 @@
// This file exports a version of the Jasmine `it` that understands promises.
// To use this, simply `import {it} from './spec-utils`.
// TODO(hansl): move this to its own Jasmine-TypeScript package.
function async(fn: () => PromiseLike<any> | void) {
return (done: DoneFn) => {
let result: PromiseLike<any> | void = null;
try {
result = fn();
if (result && 'then' in result) {
(result as Promise<any>).then(done, done.fail);
} else {
done();
}
} catch (err) {
done.fail(err);
}
};
}
export function it(description: string, fn: () => PromiseLike<any> | void) {
return (global as any)['it'](description, async(fn));
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"declaration": true,
"experimentalDecorators": true,
"mapRoot": "",
"module": "commonjs",
"moduleResolution": "node",
"noEmitOnError": true,
"noImplicitAny": true,
"outDir": "../../dist/ast-tools",
"rootDir": ".",
"sourceMap": true,
"sourceRoot": "/",
"target": "es5",
"lib": ["es6"],
"typeRoots": [
"../../node_modules/@types"
],
"types": [
"jasmine",
"node"
]
}
}

View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
'use strict';
require('../lib/bootstrap-local');
const path = require('path');
const Jasmine = require('jasmine');
const JasmineSpecReporter = require('jasmine-spec-reporter');
const projectBaseDir = path.join(__dirname, '../packages');
// Create a Jasmine runner and configure it.
const jasmine = new Jasmine({ projectBaseDir: projectBaseDir });
jasmine.loadConfig({
spec_dir: projectBaseDir
});
jasmine.addReporter(new JasmineSpecReporter());
// Run the tests.
jasmine.execute(['**/*.spec.ts']);

View File

@ -1,34 +1,7 @@
/* eslint-disable no-console */
'use strict';
const fs = require('fs');
const ts = require('typescript');
const old = require.extensions['.ts'];
require.extensions['.ts'] = function(m, filename) {
if (!filename.match(/angular-cli/) && filename.match(/node_modules/)) {
if (old) {
return old(m, filename);
}
return m._compile(fs.readFileSync(filename), filename);
}
const source = fs.readFileSync(filename).toString();
try {
const result = ts.transpile(source, {
target: ts.ScriptTarget.ES5,
module: ts.ModuleKind.CommonJs
});
// Send it to node to execute.
return m._compile(result, filename);
} catch (err) {
console.error('Error while running script "' + filename + '":');
console.error(err.stack);
throw err;
}
};
require('../lib/bootstrap-local');
var Mocha = require('mocha');
var glob = require('glob');