nx/packages/bazel/src/utils/ast-utils.ts

672 lines
18 KiB
TypeScript

/**
* @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, Rule } from '@angular-devkit/schematics';
import {
findNodes,
getDecoratorMetadata,
getSourceNodes
} from '@schematics/angular/utility/ast-utils';
import {
Change,
InsertChange,
NoopChange,
RemoveChange
} from '@schematics/angular/utility/change';
import * as ts from 'typescript';
import { toFileName } from './name-utils';
import * as path from 'path';
import { serializeJson } from '../utils/fileutils';
// 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[] {
const nodes = getDecoratorMetadata(source, 'NgModule', '@angular/core');
let node: any = nodes[0]; // tslint:disable-line:no-any
// Find the decorator declaration.
if (!node) {
return [];
}
// Get all the children property assignment of object literals.
const matchingProperties: ts.ObjectLiteralElement[] = (node as ts.ObjectLiteralExpression).properties
.filter(prop => prop.kind == ts.SyntaxKind.PropertyAssignment)
// Filter out every fields that's not "metadataField". Also handles string literals
// (but not expressions).
.filter((prop: ts.PropertyAssignment) => {
const name = prop.name;
switch (name.kind) {
case ts.SyntaxKind.Identifier:
return (name as ts.Identifier).getText(source) == metadataField;
case ts.SyntaxKind.StringLiteral:
return (name as ts.StringLiteral).text == metadataField;
}
return false;
});
// Get the last node of the array literal.
if (!matchingProperties) {
return [];
}
if (matchingProperties.length == 0) {
// We haven't found the field in the metadata declaration. Insert a new field.
const expr = node as ts.ObjectLiteralExpression;
let position: number;
let toInsert: string;
if (expr.properties.length == 0) {
position = expr.getEnd() - 1;
toInsert = ` ${metadataField}: [${expression}]\n`;
} else {
node = expr.properties[expr.properties.length - 1];
position = node.getEnd();
// Get the indentation of the last element, if any.
const text = node.getFullText(source);
if (text.match('^\r?\r?\n')) {
toInsert = `,${
text.match(/^\r?\n\s+/)[0]
}${metadataField}: [${expression}]`;
} else {
toInsert = `, ${metadataField}: [${expression}]`;
}
}
const newMetadataProperty = new InsertChange(
ngModulePath,
position,
toInsert
);
return [newMetadataProperty];
}
const assignment = matchingProperties[0] as ts.PropertyAssignment;
// If it's not an array, nothing we can do really.
if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) {
return [];
}
const arrLiteral = assignment.initializer as ts.ArrayLiteralExpression;
if (arrLiteral.elements.length == 0) {
// Forward the property.
node = arrLiteral;
} else {
node = arrLiteral.elements;
}
if (!node) {
console.log(
'No app module found. Please add your new class to your component.'
);
return [];
}
if (Array.isArray(node)) {
const nodeArray = (node as {}) as Array<ts.Node>;
const symbolsArray = nodeArray.map(node => node.getText());
if (symbolsArray.includes(expression)) {
return [];
}
node = node[node.length - 1];
}
let toInsert: string;
let position = node.getEnd();
if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) {
// We haven't found the field in the metadata declaration. Insert a new
// field.
const expr = node as ts.ObjectLiteralExpression;
if (expr.properties.length == 0) {
position = expr.getEnd() - 1;
toInsert = ` ${metadataField}: [${expression}]\n`;
} else {
node = expr.properties[expr.properties.length - 1];
position = node.getEnd();
// Get the indentation of the last element, if any.
const text = node.getFullText(source);
if (text.match('^\r?\r?\n')) {
toInsert = `,${
text.match(/^\r?\n\s+/)[0]
}${metadataField}: [${expression}]`;
} else {
toInsert = `, ${metadataField}: [${expression}]`;
}
}
} else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) {
// We found the field but it's empty. Insert it just before the `]`.
position--;
toInsert = `${expression}`;
} else {
// Get the indentation of the last element, if any.
const text = node.getFullText(source);
if (text.match(/^\r?\n/)) {
toInsert = `,${text.match(/^\r?\n(\r?)\s+/)[0]}${expression}`;
} else {
toInsert = `, ${expression}`;
}
}
const insert = new InsertChange(ngModulePath, position, toInsert);
return [insert];
}
export function 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 removeFromNgModule(
source: ts.SourceFile,
modulePath: string,
property: string
): Change[] {
const nodes = getDecoratorMetadata(source, 'NgModule', '@angular/core');
let node: any = nodes[0]; // tslint:disable-line:no-any
// Find the decorator declaration.
if (!node) {
return [];
}
// Get all the children property assignment of object literals.
const matchingProperty = getMatchingProperty(source, property);
if (matchingProperty) {
return [
new RemoveChange(
modulePath,
matchingProperty.pos,
matchingProperty.getFullText(source)
)
];
} else {
return [];
}
}
function findClass(
source: ts.SourceFile,
className: string
): ts.ClassDeclaration {
const nodes = getSourceNodes(source);
const clazz = <any>(
nodes.filter(
n =>
n.kind === ts.SyntaxKind.ClassDeclaration &&
(<any>n).name.text === className
)[0]
);
if (!clazz) {
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 addImportToModule(
source: ts.SourceFile,
modulePath: string,
symbolName: string
): Change[] {
return _addSymbolToNgModuleMetadata(
source,
modulePath,
'imports',
symbolName
);
}
export function addImportToTestBed(
source: ts.SourceFile,
specPath: string,
symbolName: string
): Change[] {
const allCalls: ts.CallExpression[] = <any>(
findNodes(source, ts.SyntaxKind.CallExpression)
);
const configureTestingModuleObjectLiterals = allCalls
.filter(c => c.expression.kind === ts.SyntaxKind.PropertyAccessExpression)
.filter(
(c: any) => c.expression.name.getText(source) === 'configureTestingModule'
)
.map(c =>
c.arguments[0].kind === ts.SyntaxKind.ObjectLiteralExpression
? c.arguments[0]
: null
);
if (configureTestingModuleObjectLiterals.length > 0) {
const startPosition = configureTestingModuleObjectLiterals[0]
.getFirstToken(source)
.getEnd();
return [
new InsertChange(specPath, startPosition, `imports: [${symbolName}], `)
];
} else {
return [];
}
}
export function addReexport(
source: ts.SourceFile,
modulePath: string,
reexportedFileName: string,
token: string
): Change[] {
const allExports = findNodes(source, ts.SyntaxKind.ExportDeclaration);
if (allExports.length > 0) {
const m = allExports.filter(
(e: ts.ExportDeclaration) =>
e.moduleSpecifier.getText(source).indexOf(reexportedFileName) > -1
);
if (m.length > 0) {
const mm: ts.ExportDeclaration = <any>m[0];
return [
new InsertChange(modulePath, mm.exportClause.end - 1, `, ${token} `)
];
}
}
return [];
}
export function getBootstrapComponent(
source: ts.SourceFile,
moduleClassName: string
): string {
const bootstrap = getMatchingProperty(source, 'bootstrap');
if (!bootstrap) {
throw new Error(`Cannot find bootstrap components in '${moduleClassName}'`);
}
const c = bootstrap.getChildren();
const nodes = c[c.length - 1].getChildren();
const bootstrapComponent = nodes.slice(1, nodes.length - 1)[0];
if (!bootstrapComponent) {
throw new Error(`Cannot find bootstrap components in '${moduleClassName}'`);
}
return bootstrapComponent.getText();
}
function getMatchingObjectLiteralElement(
node: any,
source: ts.SourceFile,
property: string
) {
return (
(node as ts.ObjectLiteralExpression).properties
.filter(prop => prop.kind == ts.SyntaxKind.PropertyAssignment)
// Filter out every fields that's not "metadataField". Also handles string literals
// (but not expressions).
.filter((prop: ts.PropertyAssignment) => {
const name = prop.name;
switch (name.kind) {
case ts.SyntaxKind.Identifier:
return (name as ts.Identifier).getText(source) === property;
case ts.SyntaxKind.StringLiteral:
return (name as ts.StringLiteral).text === property;
}
return false;
})[0]
);
}
function getMatchingProperty(
source: ts.SourceFile,
property: string
): ts.ObjectLiteralElement {
const nodes = getDecoratorMetadata(source, 'NgModule', '@angular/core');
let node: any = nodes[0]; // tslint:disable-line:no-any
if (!node) return null;
// Get all the children property assignment of object literals.
return getMatchingObjectLiteralElement(node, source, property);
}
export function addRoute(
ngModulePath: string,
source: ts.SourceFile,
route: string
): Change[] {
const routes = getListOfRoutes(source);
if (!routes) return [];
if (routes.hasTrailingComma || routes.length === 0) {
return [new InsertChange(ngModulePath, routes.end, route)];
} else {
return [new InsertChange(ngModulePath, routes.end, `, ${route}`)];
}
}
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 [];
}
}
function getListOfRoutes(source: ts.SourceFile): ts.NodeArray<ts.Expression> {
const imports: any = getMatchingProperty(source, 'imports');
if (imports.initializer.kind === ts.SyntaxKind.ArrayLiteralExpression) {
const a = imports.initializer as ts.ArrayLiteralExpression;
for (let e of a.elements) {
if (e.kind === 181) {
const ee = e as ts.CallExpression;
const text = ee.expression.getText(source);
if (
(text === 'RouterModule.forRoot' ||
text === 'RouterModule.forChild') &&
ee.arguments.length > 0
) {
const routes = ee.arguments[0];
if (routes.kind === ts.SyntaxKind.ArrayLiteralExpression) {
return (routes as ts.ArrayLiteralExpression).elements;
}
}
}
}
}
return null;
}
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 addProviderToModule(
source: ts.SourceFile,
modulePath: string,
symbolName: string
): Change[] {
return _addSymbolToNgModuleMetadata(
source,
modulePath,
'providers',
symbolName
);
}
export function addDeclarationToModule(
source: ts.SourceFile,
modulePath: string,
symbolName: string
): Change[] {
return _addSymbolToNgModuleMetadata(
source,
modulePath,
'declarations',
symbolName
);
}
export function addEntryComponents(
source: ts.SourceFile,
modulePath: string,
symbolName: string
): Change[] {
return _addSymbolToNgModuleMetadata(
source,
modulePath,
'entryComponents',
symbolName
);
}
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: 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 RemoveChange) {
recorder.remove((<any>change).pos - 1, (<any>change).toRemove.length + 1);
} else if (change instanceof NoopChange) {
// do nothing
} else {
throw new Error(`Unexpected Change '${change}'`);
}
}
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<T = any>(host: Tree, path: string): T {
if (!host.exists(path)) {
throw new Error(`Cannot find ${path}`);
}
return JSON.parse(host.read(path)!.toString('utf-8'));
}
/**
* 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<T = any, O = T>(
path: string,
callback: (json: T) => O
): Rule {
return (host: Tree): Tree => {
host.overwrite(path, serializeJson(callback(readJsonInTree(host, path))));
return host;
};
}
/**
* This method is specifically for getting the .angular-cli.json data from a Tree
* @param host The host tree
*/
export function getAngularCliConfig(host: Tree) {
return readJsonInTree(host, '.angular-cli.json');
}
export function getAppConfig(host: Tree, name: string): any {
const angularCliJson = getAngularCliConfig(host);
const apps = angularCliJson.apps;
if (!apps || apps.length === 0) {
throw new Error(`Cannot find app '${name}'`);
}
if (name) {
const appConfig = apps.filter(a => a.name === name)[0];
if (!appConfig) {
throw new Error(`Cannot find app '${name}'`);
} else {
return appConfig;
}
}
return apps[0];
}
export function readBootstrapInfo(
host: Tree,
app: string
): {
moduleSpec: string;
modulePath: string;
mainPath: string;
moduleClassName: string;
moduleSource: ts.SourceFile;
bootstrapComponentClassName: string;
bootstrapComponentFileName: string;
} {
const config = getAppConfig(host, app);
const mainPath = path.join(config.root, config.main);
if (!host.exists(mainPath)) {
throw new Error('Main file cannot be located');
}
const mainSource = host.read(mainPath)!.toString('utf-8');
const main = ts.createSourceFile(
mainPath,
mainSource,
ts.ScriptTarget.Latest,
true
);
const moduleImports = getImport(
main,
(s: string) => s.indexOf('.module') > -1
);
if (moduleImports.length !== 1) {
throw new Error(`main.ts can only import a single module`);
}
const moduleImport = moduleImports[0];
const moduleClassName = moduleImport.bindings.filter(b =>
b.endsWith('Module')
)[0];
const modulePath = `${path.join(
path.dirname(mainPath),
moduleImport.moduleSpec
)}.ts`;
if (!host.exists(modulePath)) {
throw new Error(`Cannot find '${modulePath}'`);
}
const moduleSourceText = host.read(modulePath)!.toString('utf-8');
const moduleSource = ts.createSourceFile(
modulePath,
moduleSourceText,
ts.ScriptTarget.Latest,
true
);
const bootstrapComponentClassName = getBootstrapComponent(
moduleSource,
moduleClassName
);
const bootstrapComponentFileName = `./${path.join(
path.dirname(moduleImport.moduleSpec),
`${toFileName(
bootstrapComponentClassName.substring(
0,
bootstrapComponentClassName.length - 9
)
)}.component`
)}`;
return {
moduleSpec: moduleImport.moduleSpec,
mainPath,
modulePath,
moduleSource,
moduleClassName,
bootstrapComponentClassName,
bootstrapComponentFileName
};
}