diff --git a/src/schematics/utility/ast-utils.ts b/src/schematics/utility/ast-utils.ts index 13696695b4..211039215b 100644 --- a/src/schematics/utility/ast-utils.ts +++ b/src/schematics/utility/ast-utils.ts @@ -6,217 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ import * as ts from 'typescript'; -import { Change, InsertChange } from './change'; -import { insertImport } from './route-utils'; - - -/** - * Find all nodes from the AST in the subtree of node of SyntaxKind kind. - * @param node - * @param kind - * @param max The maximum number of items to return. - * @return all nodes of kind, or [] if none is found - */ -export function findNodes(node: ts.Node, kind: ts.SyntaxKind, max = Infinity): ts.Node[] { - if (!node || max == 0) { - return []; - } - - const arr: ts.Node[] = []; - if (node.kind === kind) { - arr.push(node); - max--; - } - if (max > 0) { - for (const child of node.getChildren()) { - findNodes(child, kind, max).forEach(node => { - if (max > 0) { - arr.push(node); - } - max--; - }); - - if (max <= 0) { - break; - } - } - } - - return arr; -} - - -/** - * Get all the nodes from a source. - * @param sourceFile The source file object. - * @returns {Observable} An observable of all the nodes in the source. - */ -export function getSourceNodes(sourceFile: ts.SourceFile): ts.Node[] { - const nodes: ts.Node[] = [sourceFile]; - const result = []; - - while (nodes.length > 0) { - const node = nodes.shift(); - - if (node) { - result.push(node); - if (node.getChildCount(sourceFile) >= 0) { - nodes.unshift(...node.getChildren()); - } - } - } - - return result; -} - - -/** - * Helper for sorting nodes. - * @return function to sort nodes in increasing order of position in sourceFile - */ -function nodesByPosition(first: ts.Node, second: ts.Node): number { - return first.pos - second.pos; -} - - -/** - * Insert `toInsert` after the last occurence of `ts.SyntaxKind[nodes[i].kind]` - * or after the last of occurence of `syntaxKind` if the last occurence is a sub child - * of ts.SyntaxKind[nodes[i].kind] and save the changes in file. - * - * @param nodes insert after the last occurence of nodes - * @param toInsert string to insert - * @param file file to insert changes into - * @param fallbackPos position to insert if toInsert happens to be the first occurence - * @param syntaxKind the ts.SyntaxKind of the subchildren to insert after - * @return Change instance - * @throw Error if toInsert is first occurence but fall back is not set - */ -export function insertAfterLastOccurrence(nodes: ts.Node[], - toInsert: string, - file: string, - fallbackPos: number, - syntaxKind?: ts.SyntaxKind): Change { - let lastItem = nodes.sort(nodesByPosition).pop(); - if (syntaxKind) { - lastItem = findNodes(lastItem !, syntaxKind).sort(nodesByPosition).pop(); - } - if (!lastItem && fallbackPos == undefined) { - throw new Error(`tried to insert ${toInsert} as first occurence with no fallback position`); - } - const lastItemPosition: number = lastItem ? lastItem.end : fallbackPos; - - return new InsertChange(file, lastItemPosition, toInsert); -} - - -export function getContentOfKeyLiteral(_source: ts.SourceFile, node: ts.Node): string | null { - if (node.kind == ts.SyntaxKind.Identifier) { - return (node as ts.Identifier).text; - } else if (node.kind == ts.SyntaxKind.StringLiteral) { - return (node as ts.StringLiteral).text; - } else { - return null; - } -} - - -function _angularImportsFromNode(node: ts.ImportDeclaration, - _sourceFile: ts.SourceFile): {[name: string]: string} { - const ms = node.moduleSpecifier; - let modulePath: string | null = null; - switch (ms.kind) { - case ts.SyntaxKind.StringLiteral: - modulePath = (ms as ts.StringLiteral).text; - break; - default: - return {}; - } - - if (!modulePath.startsWith('@angular/')) { - return {}; - } - - if (node.importClause) { - if (node.importClause.name) { - // This is of the form `import Name from 'path'`. Ignore. - return {}; - } else if (node.importClause.namedBindings) { - const nb = node.importClause.namedBindings; - if (nb.kind == ts.SyntaxKind.NamespaceImport) { - // This is of the form `import * as name from 'path'`. Return `name.`. - return { - [(nb as ts.NamespaceImport).name.text + '.']: modulePath, - }; - } else { - // This is of the form `import {a,b,c} from 'path'` - const namedImports = nb as ts.NamedImports; - - return namedImports.elements - .map((is: ts.ImportSpecifier) => is.propertyName ? is.propertyName.text : is.name.text) - .reduce((acc: {[name: string]: string}, curr: string) => { - acc[curr] = modulePath !; - - return acc; - }, {}); - } - } - - return {}; - } else { - // This is of the form `import 'path';`. Nothing to do. - return {}; - } -} - - -export function getDecoratorMetadata(source: ts.SourceFile, identifier: string, - module: string): ts.Node[] { - const angularImports: {[name: string]: string} - = findNodes(source, ts.SyntaxKind.ImportDeclaration) - .map((node: ts.ImportDeclaration) => _angularImportsFromNode(node, source)) - .reduce((acc: {[name: string]: string}, current: {[name: string]: string}) => { - for (const key of Object.keys(current)) { - acc[key] = current[key]; - } - - return acc; - }, {}); - - return getSourceNodes(source) - .filter(node => { - return node.kind == ts.SyntaxKind.Decorator - && (node as ts.Decorator).expression.kind == ts.SyntaxKind.CallExpression; - }) - .map(node => (node as ts.Decorator).expression as ts.CallExpression) - .filter(expr => { - if (expr.expression.kind == ts.SyntaxKind.Identifier) { - const id = expr.expression as ts.Identifier; - - return id.getFullText(source) == identifier - && angularImports[id.getFullText(source)] === module; - } else if (expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression) { - // This covers foo.NgModule when importing * as foo. - const paExpr = expr.expression as ts.PropertyAccessExpression; - // If the left expression is not an identifier, just give up at that point. - if (paExpr.expression.kind !== ts.SyntaxKind.Identifier) { - return false; - } - - const id = paExpr.name.text; - const moduleId = (paExpr.expression as ts.Identifier).getText(source); - - return id === identifier && (angularImports[moduleId + '.'] === module); - } - - return false; - }) - .filter(expr => expr.arguments[0] - && expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression) - .map(expr => expr.arguments[0] as ts.ObjectLiteralExpression); -} +import {Tree} from '@angular-devkit/schematics'; +import {getDecoratorMetadata} from '@schematics/angular/utility/ast-utils'; +import {Change, InsertChange, NoopChange} from '@schematics/angular/utility/change'; +// This should be moved to @schematics/angular once it allows to pass custom expressions as providers function _addSymbolToNgModuleMetadata(source: ts.SourceFile, ngModulePath: string, metadataField: string, expression: string): Change[] { @@ -351,3 +146,18 @@ export function addProviderToModule(source: ts.SourceFile, modulePath: string, symbolName: string): Change[] { return _addSymbolToNgModuleMetadata(source, modulePath, 'providers', symbolName); } + + +export function insert(host: Tree, modulePath: string, changes: Change[]) { + const recorder = host.beginUpdate(modulePath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } else if (change instanceof NoopChange) { + // do nothing + } else { + throw new Error(`Unexpected Change '${change}'`); + } + } + host.commitUpdate(recorder); +} diff --git a/src/schematics/utility/change.ts b/src/schematics/utility/change.ts deleted file mode 100644 index 12556352ab..0000000000 --- a/src/schematics/utility/change.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -export interface Host { - write(path: string, content: string): Promise; - read(path: string): Promise; -} - - -export interface Change { - apply(host: Host): Promise; - - // The file this change should be applied to. Some changes might not apply to - // a file (maybe the config). - readonly path: string | null; - - // The order this change should be applied. Normally the position inside the file. - // Changes are applied from the bottom of a file to the top. - readonly order: number; - - // The description of this change. This will be outputted in a dry or verbose run. - readonly description: string; -} - - -/** - * An operation that does nothing. - */ -export class NoopChange implements Change { - description = 'No operation.'; - order = Infinity; - path = null; - apply() { return Promise.resolve(); } -} - - -/** - * Will add text to the source code. - */ -export class InsertChange implements Change { - - order: number; - description: string; - - constructor(public path: string, public pos: number, public toAdd: string) { - if (pos < 0) { - throw new Error('Negative positions are invalid'); - } - this.description = `Inserted ${toAdd} into position ${pos} of ${path}`; - this.order = pos; - } - - /** - * This method does not insert spaces if there is none in the original string. - */ - apply(host: Host) { - return host.read(this.path).then(content => { - const prefix = content.substring(0, this.pos); - const suffix = content.substring(this.pos); - - return host.write(this.path, `${prefix}${this.toAdd}${suffix}`); - }); - } -} - -/** - * Will remove text from the source code. - */ -export class RemoveChange implements Change { - - order: number; - description: string; - - constructor(public path: string, private pos: number, private toRemove: string) { - if (pos < 0) { - throw new Error('Negative positions are invalid'); - } - this.description = `Removed ${toRemove} into position ${pos} of ${path}`; - this.order = pos; - } - - apply(host: Host): Promise { - return host.read(this.path).then(content => { - const prefix = content.substring(0, this.pos); - const suffix = content.substring(this.pos + this.toRemove.length); - - // TODO: throw error if toRemove doesn't match removed string. - return host.write(this.path, `${prefix}${suffix}`); - }); - } -} - -/** - * Will replace text from the source code. - */ -export class ReplaceChange implements Change { - order: number; - description: string; - - constructor(public path: string, private pos: number, private oldText: string, - private newText: string) { - if (pos < 0) { - throw new Error('Negative positions are invalid'); - } - this.description = `Replaced ${oldText} into position ${pos} of ${path} with ${newText}`; - this.order = pos; - } - - apply(host: Host): Promise { - return host.read(this.path).then(content => { - const prefix = content.substring(0, this.pos); - const suffix = content.substring(this.pos + this.oldText.length); - const text = content.substring(this.pos, this.pos + this.oldText.length); - - if (text !== this.oldText) { - return Promise.reject(new Error(`Invalid replace: "${text}" != "${this.oldText}".`)); - } - - // TODO: throw error if oldText doesn't match removed string. - return host.write(this.path, `${prefix}${this.newText}${suffix}`); - }); - } -} diff --git a/src/schematics/utility/find-module.ts b/src/schematics/utility/find-module.ts deleted file mode 100644 index 7651f2a788..0000000000 --- a/src/schematics/utility/find-module.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { Tree, normalizePath } from '@angular-devkit/schematics'; -import * as path from 'path'; - -/** - * Function to find the "closest" module to a generated file's path. - */ -export function findModule(host: Tree, generateDir: string): string { - let closestModule = generateDir; - const allFiles = host.files; - - let modulePath: string | null = null; - const moduleRe = /\.module\.ts$/; - while (closestModule) { - const normalizedRoot = normalizePath(closestModule); - const matches = allFiles.filter(p => moduleRe.test(p) && p.startsWith(normalizedRoot)); - - if (matches.length == 1) { - modulePath = matches[0]; - break; - } else if (matches.length > 1) { - throw new Error('More than one module matches. Use skip-import option to skip importing ' - + 'the component into the closest module.'); - } - closestModule = closestModule.split('/').slice(0, -1).join('/'); - } - - if (!modulePath) { - throw new Error('Could not find an NgModule for the new component. Use the skip-import ' - + 'option to skip importing components in NgModule.'); - } - - return modulePath; -} - -/** - * Build a relative path from one file path to another file path. - */ -export function buildRelativePath(from: string, to: string) { - // Convert to arrays. - const fromParts = from.split('/'); - const toParts = to.split('/'); - - // Remove file names (preserving destination) - fromParts.pop(); - const toFileName = toParts.pop(); - - const relativePath = path.relative(fromParts.join('/'), toParts.join('/')); - let pathPrefix = ''; - - // Set the path prefix for same dir or child dir, parent dir starts with `..` - if (!relativePath) { - pathPrefix = '.'; - } else if (!relativePath.startsWith('.')) { - pathPrefix = `./`; - } - - return `${pathPrefix}${relativePath}/${toFileName}`; -} diff --git a/src/schematics/utility/route-utils.ts b/src/schematics/utility/route-utils.ts deleted file mode 100644 index a360aa8687..0000000000 --- a/src/schematics/utility/route-utils.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import * as ts from 'typescript'; -import { findNodes, insertAfterLastOccurrence } from './ast-utils'; -import { Change, NoopChange } from './change'; - - -/** -* Add Import `import { symbolName } from fileName` if the import doesn't exit -* already. Assumes fileToEdit can be resolved and accessed. -* @param fileToEdit (file we want to add import to) -* @param symbolName (item to import) -* @param fileName (path to the file) -* @param isDefault (if true, import follows style for importing default exports) -* @return Change -*/ - -export function insertImport(source: ts.SourceFile, fileToEdit: string, symbolName: string, - fileName: string, isDefault = false): Change { - const rootNode = source; - const allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); - - // get nodes that map to import statements from the file fileName - const relevantImports = allImports.filter(node => { - // StringLiteral of the ImportDeclaration is the import file (fileName in this case). - const importFiles = node.getChildren() - .filter(child => child.kind === ts.SyntaxKind.StringLiteral) - .map(n => (n as ts.StringLiteral).text); - - return importFiles.filter(file => file === fileName).length === 1; - }); - - if (relevantImports.length > 0) { - let importsAsterisk = false; - // imports from import file - const imports: ts.Node[] = []; - relevantImports.forEach(n => { - Array.prototype.push.apply(imports, findNodes(n, ts.SyntaxKind.Identifier)); - if (findNodes(n, ts.SyntaxKind.AsteriskToken).length > 0) { - importsAsterisk = true; - } - }); - - // if imports * from fileName, don't add symbolName - if (importsAsterisk) { - return new NoopChange(); - } - - const importTextNodes = imports.filter(n => (n as ts.Identifier).text === symbolName); - - // insert import if it's not there - if (importTextNodes.length === 0) { - const fallbackPos = findNodes(relevantImports[0], ts.SyntaxKind.CloseBraceToken)[0].pos || - findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].pos; - - return insertAfterLastOccurrence(imports, `, ${symbolName}`, fileToEdit, fallbackPos); - } - - return new NoopChange(); - } - - // no such import declaration exists - const useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral) - .filter((n: ts.StringLiteral) => n.text === 'use strict'); - let fallbackPos = 0; - if (useStrict.length > 0) { - fallbackPos = useStrict[0].end; - } - const open = isDefault ? '' : '{ '; - const close = isDefault ? '' : ' }'; - // if there are no imports or 'use strict' statement, insert import at beginning of file - const insertAtBeginning = allImports.length === 0 && useStrict.length === 0; - const separator = insertAtBeginning ? '' : ';\n'; - const toInsert = `${separator}import ${open}${symbolName}${close}` + - ` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`; - - return insertAfterLastOccurrence( - allImports, - toInsert, - fileToEdit, - fallbackPos, - ts.SyntaxKind.StringLiteral, - ); -} -