nx/packages/angular/src/utils/ast-utils.ts
Isaac Mann c60857a6df feat(nx): storybook schematics
chore(nx): move some schematics into @nrwl/angular

    chore(nx): add e2e test

    feat(nx): add storybook schematics and builder

    test: Add tests

    test: remove test

    chore: Move some schematics into @nrwl/angular

    test: fix tests

    chore: Add e2e test

    chore: Bump storybook versions

    chore: createTestUiLib takes a libName argument

    chore: configs extend root config

    fix: Remove custom scss loader

    feat: add storybook builder

    fix: generate documentation and fix linting

    chore: Builder tests passing

    fix(nx): configuration schematic can run multiple times

    feat(nx): component story format

    docs(nx): update storybook docs

    fix(nx): pin core-js dev dependency to ^2.6.9

    chore(nx): fix formatting

    chore(nx): match core-js version for web/storybook

    chore(nx): remove storybook's angular dependencies

    chore(nx): modifyObstructiveCode to false in cypress.json

    See: https://github.com/cypress-io/cypress/issues/4042#issuecomment-487562075

    chore(nx): storybook tests --no-interactive

    chore(nx): add storybook to e2e tests

    chore(nx): reorder e2e tests
2019-10-23 17:16:19 -04:00

650 lines
17 KiB
TypeScript

import * as ts from 'typescript';
import {
findNodes,
Change,
getImport,
getProjectConfig,
getSourceNodes,
InsertChange,
RemoveChange
} from '@nrwl/workspace/src/utils/ast-utils';
import { Tree, SchematicsException } from '@angular-devkit/schematics';
import * as path from 'path';
import { toFileName } from '@nrwl/workspace/src/utils/name-utils';
function _angularImportsFromNode(
node: ts.ImportDeclaration,
_sourceFile: ts.SourceFile
): { [name: string]: string } {
const ms = node.moduleSpecifier;
let modulePath: string;
switch (ms.kind) {
case ts.SyntaxKind.StringLiteral:
modulePath = (ms as ts.StringLiteral).text;
break;
default:
return {};
}
if (!modulePath.startsWith('@angular/')) {
return {};
}
if (node.importClause) {
if (node.importClause.name) {
// This is of the form `import Name from 'path'`. Ignore.
return {};
} else if (node.importClause.namedBindings) {
const nb = node.importClause.namedBindings;
if (nb.kind == ts.SyntaxKind.NamespaceImport) {
// This is of the form `import * as name from 'path'`. Return `name.`.
return {
[(nb as ts.NamespaceImport).name.text + '.']: modulePath
};
} else {
// This is of the form `import {a,b,c} from 'path'`
const namedImports = nb as ts.NamedImports;
return namedImports.elements
.map((is: ts.ImportSpecifier) =>
is.propertyName ? is.propertyName.text : is.name.text
)
.reduce((acc: { [name: string]: string }, curr: string) => {
acc[curr] = modulePath;
return acc;
}, {});
}
}
return {};
} else {
// This is of the form `import 'path';`. Nothing to do.
return {};
}
}
export function getDecoratorMetadata(
source: ts.SourceFile,
identifier: string,
module: string
): ts.Node[] {
const angularImports: { [name: string]: string } = findNodes(
source,
ts.SyntaxKind.ImportDeclaration
)
.map((node: ts.ImportDeclaration) => _angularImportsFromNode(node, source))
.reduce(
(
acc: { [name: string]: string },
current: { [name: string]: string }
) => {
for (const key of Object.keys(current)) {
acc[key] = current[key];
}
return acc;
},
{}
);
return getSourceNodes(source)
.filter(node => {
return (
node.kind == ts.SyntaxKind.Decorator &&
(node as ts.Decorator).expression.kind == ts.SyntaxKind.CallExpression
);
})
.map(node => (node as ts.Decorator).expression as ts.CallExpression)
.filter(expr => {
if (expr.expression.kind == ts.SyntaxKind.Identifier) {
const id = expr.expression as ts.Identifier;
return (
id.getFullText(source) == identifier &&
angularImports[id.getFullText(source)] === module
);
} else if (
expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression
) {
// This covers foo.NgModule when importing * as foo.
const paExpr = expr.expression as ts.PropertyAccessExpression;
// If the left expression is not an identifier, just give up at that point.
if (paExpr.expression.kind !== ts.SyntaxKind.Identifier) {
return false;
}
const id = paExpr.name.text;
const moduleId = (paExpr.expression as ts.Identifier).getText(source);
return id === identifier && angularImports[moduleId + '.'] === module;
}
return false;
})
.filter(
expr =>
expr.arguments[0] &&
expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression
)
.map(expr => expr.arguments[0] as ts.ObjectLiteralExpression);
}
function findClassDeclarationParent(node) {
if (ts.isClassDeclaration(node)) {
return node;
}
return node.parent && findClassDeclarationParent(node.parent);
}
/**
* Given a source file with @NgModule class(es), find the name of the first @NgModule class.
*
* @param source source file containing one or more @NgModule
* @returns the name of the first @NgModule, or `undefined` if none is found
*/
export function getFirstNgModuleName(source) {
// First, find the @NgModule decorators.
const ngModulesMetadata = getDecoratorMetadata(
source,
'NgModule',
'@angular/core'
);
if (ngModulesMetadata.length === 0) {
return undefined;
}
// Then walk parent pointers up the AST, looking for the ClassDeclaration parent of the NgModule
// metadata.
const moduleClass = findClassDeclarationParent(ngModulesMetadata[0]);
if (!moduleClass || !moduleClass.name) {
return undefined;
}
// Get the class name of the module ClassDeclaration.
return moduleClass.name.text;
}
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 [];
}
const isArray = Array.isArray(node);
if (isArray) {
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 (!isArray && 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 (!isArray && 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 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,
'NgModule',
'@angular/core'
);
if (matchingProperty) {
return [
new RemoveChange(
modulePath,
matchingProperty.getStart(source),
matchingProperty.getFullText(source)
)
];
} else {
return [];
}
}
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 getBootstrapComponent(
source: ts.SourceFile,
moduleClassName: string
): string {
const bootstrap = getMatchingProperty(
source,
'bootstrap',
'NgModule',
'@angular/core'
);
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 getMatchingProperty(
source: ts.SourceFile,
property: string,
identifier: string,
module: string
): ts.ObjectLiteralElement {
const nodes = getDecoratorMetadata(source, identifier, module);
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}`)];
}
}
function getListOfRoutes(source: ts.SourceFile): ts.NodeArray<ts.Expression> {
const imports: any = getMatchingProperty(
source,
'imports',
'NgModule',
'@angular/core'
);
if (imports.initializer.kind === ts.SyntaxKind.ArrayLiteralExpression) {
const a = imports.initializer as ts.ArrayLiteralExpression;
for (let e of a.elements) {
if (e.kind === ts.SyntaxKind.CallExpression) {
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 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 readBootstrapInfo(
host: Tree,
app: string
): {
moduleSpec: string;
modulePath: string;
mainPath: string;
moduleClassName: string;
moduleSource: ts.SourceFile;
bootstrapComponentClassName: string;
bootstrapComponentFileName: string;
} {
const config = getProjectConfig(host, app);
let mainPath;
try {
mainPath = config.architect.build.options.main;
} catch (e) {
throw new Error('Main file cannot be located');
}
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
};
}
export function getDecoratorPropertyValueNode(
host: Tree,
modulePath: string,
identifier: string,
property: string,
module: string
) {
const moduleSourceText = host.read(modulePath)!.toString('utf-8');
const moduleSource = ts.createSourceFile(
modulePath,
moduleSourceText,
ts.ScriptTarget.Latest,
true
);
const templateNode = getMatchingProperty(
moduleSource,
property,
identifier,
module
);
return templateNode.getChildAt(templateNode.getChildCount() - 1);
}
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]
);
}
export function getTsSourceFile(host: Tree, path: string): ts.SourceFile {
const buffer = host.read(path);
if (!buffer) {
throw new SchematicsException(`Could not read TS file (${path}).`);
}
const content = buffer.toString();
const source = ts.createSourceFile(
path,
content,
ts.ScriptTarget.Latest,
true
);
return source;
}