/** * @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 { Rule, Tree, SchematicContext } from '@angular-devkit/schematics'; import * as ts from 'typescript'; import * as stripJsonComments from 'strip-json-comments'; import { serializeJson } from './fileutils'; import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; import { getWorkspacePath } from './cli-config-utils'; import { createProjectGraph, ProjectGraph } from '../command-line/project-graph'; import { NxJson } from '../command-line/shared'; import { FileData } from '../command-line/file-utils'; import { extname, join, normalize, Path } from '@angular-devkit/core'; function nodesByPosition(first: ts.Node, second: ts.Node): number { return first.getStart() - second.getStart(); } function insertAfterLastOccurrence( nodes: ts.Node[], toInsert: string, file: string, fallbackPos: number, syntaxKind?: ts.SyntaxKind ): Change { // sort() has a side effect, so make a copy so that we won't overwrite the parent's object. let lastItem = [...nodes].sort(nodesByPosition).pop(); if (!lastItem) { throw new Error(); } 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.getEnd() : fallbackPos; return new InsertChange(file, lastItemPosition, toInsert); } export function findNodes( node: ts.Node, kind: ts.SyntaxKind | ts.SyntaxKind[], max = Infinity ): ts.Node[] { if (!node || max == 0) { return []; } const arr: ts.Node[] = []; const hasMatch = Array.isArray(kind) ? kind.includes(node.kind) : node.kind === kind; if (hasMatch) { 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 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; } export interface Change { apply(host: any): Promise; readonly type: string; readonly path: string | null; readonly order: number; readonly description: string; } export class NoopChange implements Change { type = 'noop'; description = 'No operation.'; order = Infinity; path = null; apply() { return Promise.resolve(); } } export class InsertChange implements Change { type = 'insert'; 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; } apply(host: any) { 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}`); }); } } export class RemoveChange implements Change { type = 'remove'; 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: any): Promise { return host.read(this.path).then(content => { const prefix = content.substring(0, this.pos); const suffix = content.substring(this.pos + this.toRemove.length); return host.write(this.path, `${prefix}${suffix}`); }); } } export class ReplaceChange implements Change { type = 'replace'; 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: any): 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}".`) ); } return host.write(this.path, `${prefix}${this.newText}${suffix}`); }); } } export function addParameterToConstructor( source: ts.SourceFile, modulePath: string, opts: { className: string; param: string } ): Change[] { const clazz = findClass(source, opts.className); const constructor = clazz.members.filter( m => m.kind === ts.SyntaxKind.Constructor )[0]; if (constructor) { throw new Error('Should be tested'); } else { const methodHeader = `constructor(${opts.param})`; return addMethod(source, modulePath, { className: opts.className, methodHeader, body: null }); } } export function addMethod( source: ts.SourceFile, modulePath: string, opts: { className: string; methodHeader: string; body: string } ): Change[] { const clazz = findClass(source, opts.className); const body = opts.body ? ` ${opts.methodHeader} { ${offset(opts.body, 1, false)} } ` : ` ${opts.methodHeader} {} `; return [new InsertChange(modulePath, clazz.end - 1, offset(body, 1, true))]; } export function findClass( source: ts.SourceFile, className: string, silent: boolean = false ): ts.ClassDeclaration { const nodes = getSourceNodes(source); const clazz = ( nodes.filter( n => n.kind === ts.SyntaxKind.ClassDeclaration && (n).name.text === className )[0] ); if (!clazz && !silent) { throw new Error(`Cannot find class '${className}'`); } return clazz; } export function offset( text: string, numberOfTabs: number, wrap: boolean ): string { const lines = text .trim() .split('\n') .map(line => { let tabs = ''; for (let c = 0; c < numberOfTabs; ++c) { tabs += ' '; } return `${tabs}${line}`; }) .join('\n'); return wrap ? `\n${lines}\n` : lines; } export function addIncludeToTsConfig( tsConfigPath: string, source: ts.SourceFile, include: string ): Change[] { const includeKeywordPos = source.text.indexOf('"include":'); if (includeKeywordPos > -1) { const includeArrayEndPos = source.text.indexOf(']', includeKeywordPos); return [new InsertChange(tsConfigPath, includeArrayEndPos, include)]; } else { return []; } } export function getImport( source: ts.SourceFile, predicate: (a: any) => boolean ): { moduleSpec: string; bindings: string[] }[] { const allImports = findNodes(source, ts.SyntaxKind.ImportDeclaration); const matching = allImports.filter((i: ts.ImportDeclaration) => predicate(i.moduleSpecifier.getText()) ); return matching.map((i: ts.ImportDeclaration) => { const moduleSpec = i.moduleSpecifier .getText() .substring(1, i.moduleSpecifier.getText().length - 1); const t = i.importClause.namedBindings.getText(); const bindings = t .replace('{', '') .replace('}', '') .split(',') .map(q => q.trim()); return { moduleSpec, bindings }; }); } export function addGlobal( source: ts.SourceFile, modulePath: string, statement: string ): Change[] { const allImports = findNodes(source, ts.SyntaxKind.ImportDeclaration); if (allImports.length > 0) { const lastImport = allImports[allImports.length - 1]; return [ new InsertChange(modulePath, lastImport.end + 1, `\n${statement}\n`) ]; } else { return [new InsertChange(modulePath, 0, `${statement}\n`)]; } } export function insert(host: Tree, modulePath: string, changes: any[]) { if (changes.length < 1) { return; } const recorder = host.beginUpdate(modulePath); for (const change of changes) { if (change.type === 'insert') { recorder.insertLeft(change.pos, change.toAdd); } else if (change.type === 'remove') { recorder.remove((change).pos - 1, (change).toRemove.length + 1); } else if (change.type === 'noop') { // do nothing } else if (change.type === 'replace') { const action = change; recorder.remove(action.pos, action.oldText.length); recorder.insertLeft(action.pos, action.newText); } else { throw new Error(`Unexpected Change '${change.constructor.name}'`); } } host.commitUpdate(recorder); } /** * This method is specifically for reading JSON files in a Tree * @param host The host tree * @param path The path to the JSON file * @returns The JSON data in the file. */ export function readJsonInTree(host: Tree, path: string): T { if (!host.exists(path)) { throw new Error(`Cannot find ${path}`); } const contents = stripJsonComments(host.read(path)!.toString('utf-8')); try { return JSON.parse(contents); } catch (e) { throw new Error(`Cannot parse ${path}: ${e.message}`); } } /** * Method for utilizing the project graph in schematics */ export function getProjectGraphFromHost(host: Tree): ProjectGraph { const workspaceJson = readJsonInTree(host, getWorkspacePath(host)); const nxJson = readJsonInTree(host, '/nx.json'); const fileRead = (f: string) => host.read(f).toString(); const workspaceFiles: FileData[] = []; const mtime = +Date.now(); workspaceFiles.push( ...allFilesInDirInHost(host, normalize(''), { recursive: false }).map(f => getFileDataInHost(host, f, mtime) ) ); workspaceFiles.push( ...allFilesInDirInHost(host, normalize('tools')).map(f => getFileDataInHost(host, f, mtime) ) ); // Add files for workspace projects Object.keys(workspaceJson.projects).forEach(projectName => { const project = workspaceJson.projects[projectName]; workspaceFiles.push( ...allFilesInDirInHost(host, normalize(project.root)).map(f => getFileDataInHost(host, f, mtime) ) ); }); return createProjectGraph( workspaceJson, nxJson, workspaceFiles, fileRead, false ); } export function getFileDataInHost( host: Tree, path: Path, mtime: number ): FileData { return { file: path, ext: extname(normalize(path)), mtime }; } export function allFilesInDirInHost( host: Tree, path: Path, options: { recursive: boolean; } = { recursive: true } ): Path[] { const dir = host.getDir(path); const res: Path[] = []; dir.subfiles.forEach(p => { res.push(join(path, p)); }); if (!options.recursive) { return res; } dir.subdirs.forEach(p => { res.push(...allFilesInDirInHost(host, join(path, p))); }); return res; } /** * This method is specifically for updating JSON in a Tree * @param path Path of JSON file in the Tree * @param callback Manipulation of the JSON data * @returns A rule which updates a JSON file file in a Tree */ export function updateJsonInTree( path: string, callback: (json: T, context: SchematicContext) => O ): Rule { return (host: Tree, context: SchematicContext): Tree => { if (!host.exists(path)) { host.create(path, serializeJson(callback({} as T, context))); return host; } host.overwrite( path, serializeJson(callback(readJsonInTree(host, path), context)) ); return host; }; } export function updateWorkspaceInTree( callback: (json: T, context: SchematicContext) => O ): Rule { return (host: Tree, context: SchematicContext): Tree => { const path = getWorkspacePath(host); host.overwrite( path, serializeJson(callback(readJsonInTree(host, path), context)) ); return host; }; } export function readWorkspace(host: Tree): any { const path = getWorkspacePath(host); return readJsonInTree(host, path); } let installAdded = false; export function addDepsToPackageJson( deps: any, devDeps: any, addInstall = true ): Rule { return updateJsonInTree('package.json', (json, context: SchematicContext) => { json.dependencies = { ...(json.dependencies || {}), ...deps, ...(json.dependencies || {}) }; json.devDependencies = { ...(json.devDependencies || {}), ...devDeps, ...(json.devDependencies || {}) }; if (addInstall && !installAdded) { context.addTask(new NodePackageInstallTask()); installAdded = true; } return json; }); } export function updatePackageJsonDependencies( deps: any, devDeps: any, addInstall = true ): Rule { return updateJsonInTree('package.json', (json, context: SchematicContext) => { json.dependencies = { ...(json.dependencies || {}), ...deps }; json.devDependencies = { ...(json.devDependencies || {}), ...devDeps }; if (addInstall && !installAdded) { context.addTask(new NodePackageInstallTask()); installAdded = true; } return json; }); } export function getProjectConfig(host: Tree, name: string): any { const workspaceJson = readJsonInTree(host, getWorkspacePath(host)); const projectConfig = workspaceJson.projects[name]; if (!projectConfig) { throw new Error(`Cannot find project '${name}'`); } else { return projectConfig; } } export function createOrUpdate(host: Tree, path: string, content: string) { if (host.exists(path)) { host.overwrite(path, content); } else { host.create(path, content); } } 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].getStart() || findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].getStart(); 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 ); } export function replaceNodeValue( host: Tree, modulePath: string, node: ts.Node, content: string ) { insert(host, modulePath, [ new ReplaceChange( modulePath, node.getStart(node.getSourceFile()), node.getFullText(), content ) ]); }