diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index 8b3f9de64ddb9..6fc202dd3ab8f 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -15,5 +15,6 @@ npm_package( "//packages/core/schematics/migrations/renderer-to-renderer2", "//packages/core/schematics/migrations/static-queries", "//packages/core/schematics/migrations/template-var-assignment", + "//packages/core/schematics/migrations/undecorated-classes-with-di", ], ) diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json index 9bf020f9dbdee..270257daa255e 100644 --- a/packages/core/schematics/migrations.json +++ b/packages/core/schematics/migrations.json @@ -19,6 +19,11 @@ "version": "9-beta", "description": "Migrates usages of Renderer to Renderer2", "factory": "./migrations/renderer-to-renderer2/index" + }, + "migration-v9-undecorated-classes-with-di": { + "version": "9-beta", + "description": "Decorates undecorated base classes of directives/components that use dependency injection. Copies metadata from base classes to derived directives/components/pipes that are not decorated.", + "factory": "./migrations/undecorated-classes-with-di/index" } } } diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/BUILD.bazel b/packages/core/schematics/migrations/undecorated-classes-with-di/BUILD.bazel new file mode 100644 index 0000000000000..9ca53e7742ca9 --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/BUILD.bazel @@ -0,0 +1,25 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "undecorated-classes-with-di", + srcs = glob(["**/*.ts"]), + tsconfig = "//packages/core/schematics:tsconfig.json", + visibility = [ + "//packages/core/schematics:__pkg__", + "//packages/core/schematics/migrations/undecorated-classes/google3:__pkg__", + "//packages/core/schematics/test:__pkg__", + ], + deps = [ + "//packages/compiler", + "//packages/compiler-cli", + "//packages/compiler-cli/src/ngtsc/imports", + "//packages/compiler-cli/src/ngtsc/partial_evaluator", + "//packages/compiler-cli/src/ngtsc/reflection", + "//packages/core", + "//packages/core/schematics/utils", + "@npm//@angular-devkit/core", + "@npm//@angular-devkit/schematics", + "@npm//@types/node", + "@npm//typescript", + ], +) diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/create_ngc_program.ts b/packages/core/schematics/migrations/undecorated-classes-with-di/create_ngc_program.ts new file mode 100644 index 0000000000000..3eb0c52a956e1 --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/create_ngc_program.ts @@ -0,0 +1,42 @@ +/** + * @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 {AotCompiler, CompileStylesheetMetadata} from '@angular/compiler'; +import {createProgram, readConfiguration} from '@angular/compiler-cli'; +import * as ts from 'typescript'; + +/** Creates an NGC program that can be used to read and parse metadata for files. */ +export function createNgcProgram( + createHost: (options: ts.CompilerOptions) => ts.CompilerHost, tsconfigPath: string | null, + parseConfig: () => { + rootNames: readonly string[], + options: ts.CompilerOptions + } = () => readConfiguration(tsconfigPath !)) { + const {rootNames, options} = parseConfig(); + const host = createHost(options); + const ngcProgram = createProgram({rootNames, options, host}); + const program = ngcProgram.getTsProgram(); + + // The "AngularCompilerProgram" does not expose the "AotCompiler" instance, nor does it + // expose the logic that is necessary to analyze the determined modules. We work around + // this by just accessing the necessary private properties using the bracket notation. + const compiler: AotCompiler = (ngcProgram as any)['compiler']; + const metadataResolver = compiler['_metadataResolver']; + // Modify the "DirectiveNormalizer" to not normalize any referenced external stylesheets. + // This is necessary because in CLI projects preprocessor files are commonly referenced + // and we don't want to parse them in order to extract relative style references. This + // breaks the analysis of the project because we instantiate a standalone AOT compiler + // program which does not contain the custom logic by the Angular CLI Webpack compiler plugin. + const directiveNormalizer = metadataResolver !['_directiveNormalizer']; + directiveNormalizer['_normalizeStylesheet'] = function(metadata: CompileStylesheetMetadata) { + return new CompileStylesheetMetadata( + {styles: metadata.styles, styleUrls: [], moduleUrl: metadata.moduleUrl !}); + }; + + return {host, ngcProgram, program, compiler}; +} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/convert_directive_metadata.ts b/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/convert_directive_metadata.ts new file mode 100644 index 0000000000000..7765cbfc05d6d --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/convert_directive_metadata.ts @@ -0,0 +1,76 @@ +/** + * @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 {StaticSymbol} from '@angular/compiler'; +import * as ts from 'typescript'; + +/** + * Converts a directive metadata object into a TypeScript expression. Throws + * if metadata cannot be cleanly converted. + */ +export function convertDirectiveMetadataToExpression( + metadata: any, resolveSymbolImport: (symbol: StaticSymbol) => string | null, + createImport: (moduleName: string, name: string) => ts.Expression, + convertProperty?: (key: string, value: any) => ts.Expression | null): ts.Expression { + if (typeof metadata === 'string') { + return ts.createStringLiteral(metadata); + } else if (Array.isArray(metadata)) { + return ts.createArrayLiteral(metadata.map( + el => convertDirectiveMetadataToExpression( + el, resolveSymbolImport, createImport, convertProperty))); + } else if (typeof metadata === 'number') { + return ts.createNumericLiteral(metadata.toString()); + } else if (typeof metadata === 'boolean') { + return metadata ? ts.createTrue() : ts.createFalse(); + } else if (typeof metadata === 'undefined') { + return ts.createIdentifier('undefined'); + } else if (typeof metadata === 'bigint') { + return ts.createBigIntLiteral(metadata.toString()); + } else if (typeof metadata === 'object') { + // In case there is a static symbol object part of the metadata, try to resolve + // the import expression of the symbol. If no import path could be resolved, an + // error will be thrown as the symbol cannot be converted into TypeScript AST. + if (metadata instanceof StaticSymbol) { + const resolvedImport = resolveSymbolImport(metadata); + if (resolvedImport === null) { + throw new UnexpectedMetadataValueError(); + } + return createImport(resolvedImport, metadata.name); + } + + const literalProperties: ts.PropertyAssignment[] = []; + + for (const key of Object.keys(metadata)) { + const metadataValue = metadata[key]; + let propertyValue: ts.Expression|null = null; + + // Allows custom conversion of properties in an object. This is useful for special + // cases where we don't want to store the enum values as integers, but rather use the + // real enum symbol. e.g. instead of `2` we want to use `ViewEncapsulation.None`. + if (convertProperty) { + propertyValue = convertProperty(key, metadataValue); + } + + // In case the property value has not been assigned to an expression, we convert + // the resolved metadata value into a TypeScript expression. + if (propertyValue === null) { + propertyValue = convertDirectiveMetadataToExpression( + metadataValue, resolveSymbolImport, createImport, convertProperty); + } + + literalProperties.push(ts.createPropertyAssignment(key, propertyValue)); + } + + return ts.createObjectLiteral(literalProperties, true); + } + + throw new UnexpectedMetadataValueError(); +} + +/** Error that will be thrown if a unexpected value needs to be converted. */ +export class UnexpectedMetadataValueError extends Error {} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/decorator_rewriter.ts b/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/decorator_rewriter.ts new file mode 100644 index 0000000000000..f2b2c4cf3244d --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/decorator_rewriter.ts @@ -0,0 +1,135 @@ + +/** + * @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 {AotCompiler} from '@angular/compiler'; +import {PartialEvaluator} from '@angular/compiler-cli/src/ngtsc/partial_evaluator'; +import * as ts from 'typescript'; + +import {NgDecorator} from '../../../utils/ng_decorators'; +import {unwrapExpression} from '../../../utils/typescript/functions'; +import {ImportManager} from '../import_manager'; + +import {ImportRewriteTransformerFactory, UnresolvedIdentifierError} from './import_rewrite_visitor'; + +/** + * Class that can be used to copy decorators to a new location. The rewriter ensures that + * identifiers and imports are rewritten to work in the new file location. Fields in a + * decorator that cannot be cleanly copied will be copied with a comment explaining that + * imports and identifiers need to be adjusted manually. + */ +export class DecoratorRewriter { + previousSourceFile: ts.SourceFile|null = null; + newSourceFile: ts.SourceFile|null = null; + + newProperties: ts.ObjectLiteralElementLike[] = []; + nonCopyableProperties: ts.ObjectLiteralElementLike[] = []; + + private importRewriterFactory = new ImportRewriteTransformerFactory( + this.importManager, this.typeChecker, this.compiler['_host']); + + constructor( + private importManager: ImportManager, private typeChecker: ts.TypeChecker, + private evaluator: PartialEvaluator, private compiler: AotCompiler) {} + + rewrite(ngDecorator: NgDecorator, newSourceFile: ts.SourceFile): ts.Decorator { + const decorator = ngDecorator.node; + + // Reset the previous state of the decorator rewriter. + this.newProperties = []; + this.nonCopyableProperties = []; + this.newSourceFile = newSourceFile; + this.previousSourceFile = decorator.getSourceFile(); + + // If the decorator will be added to the same source file it currently + // exists in, we don't need to rewrite any paths or add new imports. + if (this.previousSourceFile === newSourceFile) { + return this._createDecorator(decorator.expression); + } + + const oldCallExpr = decorator.expression; + + if (!oldCallExpr.arguments.length) { + // Re-use the original decorator if there are no arguments and nothing needs + // to be sanitized or rewritten. + return this._createDecorator(decorator.expression); + } + + const metadata = unwrapExpression(oldCallExpr.arguments[0]); + if (!ts.isObjectLiteralExpression(metadata)) { + // Re-use the original decorator as there is no metadata that can be sanitized. + return this._createDecorator(decorator.expression); + } + + metadata.properties.forEach(prop => { + // We don't handle spread assignments, accessors or method declarations automatically + // as it involves more advanced static analysis and these type of properties are not + // picked up by ngc either. + if (ts.isSpreadAssignment(prop) || ts.isAccessor(prop) || ts.isMethodDeclaration(prop)) { + this.nonCopyableProperties.push(prop); + return; + } + + const sanitizedProp = this._sanitizeMetadataProperty(prop); + if (sanitizedProp !== null) { + this.newProperties.push(sanitizedProp); + } else { + this.nonCopyableProperties.push(prop); + } + }); + + // In case there is at least one non-copyable property, we add a leading comment to + // the first property assignment in order to ask the developer to manually manage + // imports and do path rewriting for these properties. + if (this.nonCopyableProperties.length !== 0) { + ['The following fields were copied from the base class,', + 'but could not be updated automatically to work in the', + 'new file location. Please add any required imports for', 'the properties below:'] + .forEach( + text => ts.addSyntheticLeadingComment( + this.nonCopyableProperties[0], ts.SyntaxKind.SingleLineCommentTrivia, ` ${text}`, + true)); + } + + // Note that we don't update the decorator as we don't want to copy potential leading + // comments of the decorator. This is necessary because otherwise comments from the + // copied decorator end up describing the new class (which is not always correct). + return this._createDecorator(ts.createCall( + this.importManager.addImportToSourceFile( + newSourceFile, ngDecorator.name, ngDecorator.moduleName), + undefined, [ts.updateObjectLiteral( + metadata, [...this.newProperties, ...this.nonCopyableProperties])])); + } + + /** Creates a new decorator with the given expression. */ + private _createDecorator(expr: ts.Expression): ts.Decorator { + // Note that we don't update the decorator as we don't want to copy potential leading + // comments of the decorator. This is necessary because otherwise comments from the + // copied decorator end up describing the new class (which is not always correct). + return ts.createDecorator(expr); + } + + /** + * Sanitizes a metadata property by ensuring that all contained identifiers + * are imported in the target source file. + */ + private _sanitizeMetadataProperty(prop: ts.ObjectLiteralElementLike): ts.ObjectLiteralElementLike + |null { + try { + return ts + .transform(prop, [ctx => this.importRewriterFactory.create(ctx, this.newSourceFile !)]) + .transformed[0]; + } catch (e) { + // If the error is for an unresolved identifier, we want to return "null" because + // such object literal elements could be added to the non-copyable properties. + if (e instanceof UnresolvedIdentifierError) { + return null; + } + throw e; + } + } +} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/import_rewrite_visitor.ts b/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/import_rewrite_visitor.ts new file mode 100644 index 0000000000000..417687615885c --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/import_rewrite_visitor.ts @@ -0,0 +1,142 @@ +/** + * @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 {AotCompilerHost} from '@angular/compiler'; +import {dirname, resolve} from 'path'; +import * as ts from 'typescript'; + +import {Import, getImportOfIdentifier} from '../../../utils/typescript/imports'; +import {getValueSymbolOfDeclaration} from '../../../utils/typescript/symbol'; +import {ImportManager} from '../import_manager'; + +import {getPosixPath} from './path_format'; +import {ResolvedExport, getExportSymbolsOfFile} from './source_file_exports'; + +/** + * Factory that creates a TypeScript transformer which ensures that + * referenced identifiers are available at the target file location. + * + * Imports cannot be just added as sometimes identifiers collide in the + * target source file and the identifier needs to be aliased. + */ +export class ImportRewriteTransformerFactory { + private sourceFileExports = new Map(); + + constructor( + private importManager: ImportManager, private typeChecker: ts.TypeChecker, + private compilerHost: AotCompilerHost) {} + + create(ctx: ts.TransformationContext, newSourceFile: ts.SourceFile): + ts.Transformer { + const visitNode: ts.Visitor = (node: ts.Node) => { + if (ts.isIdentifier(node)) { + // Record the identifier reference and return the new identifier. The identifier + // name can change if the generated import uses an namespaced import or aliased + // import identifier (to avoid collisions). + return this._recordIdentifierReference(node, newSourceFile); + } + + return ts.visitEachChild(node, visitNode, ctx); + }; + + return (node: T) => ts.visitNode(node, visitNode); + } + + private _recordIdentifierReference(node: ts.Identifier, targetSourceFile: ts.SourceFile): + ts.Node { + // For object literal elements we don't want to check identifiers that describe the + // property name. These identifiers do not refer to a value but rather to a property + // name and therefore don't need to be imported. The exception is that for shorthand + // property assignments the "name" identifier is both used as value and property name. + if (ts.isObjectLiteralElementLike(node.parent) && + !ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node) { + return node; + } + + const resolvedImport = getImportOfIdentifier(this.typeChecker, node); + const sourceFile = node.getSourceFile(); + + if (resolvedImport) { + const symbolName = resolvedImport.name; + const moduleFileName = + this.compilerHost.moduleNameToFileName(resolvedImport.importModule, sourceFile.fileName); + + // In case the identifier refers to an export in the target source file, we need to use + // the local identifier in the scope of the target source file. This is necessary because + // the export could be aliased and the alias is not available to the target source file. + if (moduleFileName && resolve(moduleFileName) === resolve(targetSourceFile.fileName)) { + const resolvedExport = + this._getSourceFileExports(targetSourceFile).find(e => e.exportName === symbolName); + if (resolvedExport) { + return resolvedExport.identifier; + } + } + + return this.importManager.addImportToSourceFile( + targetSourceFile, symbolName, + this._rewriteModuleImport(resolvedImport, targetSourceFile)); + } else { + let symbol = getValueSymbolOfDeclaration(node, this.typeChecker); + + if (symbol) { + // If the symbol refers to a shorthand property assignment, we want to resolve the + // value symbol of the shorthand property assignment. This is necessary because the + // value symbol is ambiguous for shorthand property assignment identifiers as the + // identifier resolves to both property name and property value. + if (symbol.valueDeclaration && ts.isShorthandPropertyAssignment(symbol.valueDeclaration)) { + symbol = this.typeChecker.getShorthandAssignmentValueSymbol(symbol.valueDeclaration); + } + + const resolvedExport = + this._getSourceFileExports(sourceFile).find(e => e.symbol === symbol); + + if (resolvedExport) { + return this.importManager.addImportToSourceFile( + targetSourceFile, resolvedExport.exportName, + getPosixPath(this.compilerHost.fileNameToModuleName( + sourceFile.fileName, targetSourceFile.fileName))); + } + } + + // The referenced identifier cannot be imported. In that case we throw an exception + // which can be handled outside of the transformer. + throw new UnresolvedIdentifierError(); + } + } + + /** + * Gets the resolved exports of a given source file. Exports are cached + * for subsequent calls. + */ + private _getSourceFileExports(sourceFile: ts.SourceFile): ResolvedExport[] { + if (this.sourceFileExports.has(sourceFile)) { + return this.sourceFileExports.get(sourceFile) !; + } + + const sourceFileExports = getExportSymbolsOfFile(sourceFile, this.typeChecker); + this.sourceFileExports.set(sourceFile, sourceFileExports); + return sourceFileExports; + } + + /** Rewrites a module import to be relative to the target file location. */ + private _rewriteModuleImport(resolvedImport: Import, newSourceFile: ts.SourceFile): string { + if (!resolvedImport.importModule.startsWith('.')) { + return resolvedImport.importModule; + } + + const importFilePath = resolvedImport.node.getSourceFile().fileName; + const resolvedModulePath = resolve(dirname(importFilePath), resolvedImport.importModule); + const relativeModuleName = + this.compilerHost.fileNameToModuleName(resolvedModulePath, newSourceFile.fileName); + + return getPosixPath(relativeModuleName); + } +} + +/** Error that will be thrown if a given identifier cannot be resolved. */ +export class UnresolvedIdentifierError extends Error {} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/path_format.ts b/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/path_format.ts new file mode 100644 index 0000000000000..65f055737db16 --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/path_format.ts @@ -0,0 +1,18 @@ +/** + * @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 {normalize} from 'path'; + +/** Normalizes the specified path to conform with the posix path format. */ +export function getPosixPath(pathString: string) { + const normalized = normalize(pathString).replace(/\\/g, '/'); + if (!normalized.startsWith('.')) { + return `./${normalized}`; + } + return normalized; +} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/source_file_exports.ts b/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/source_file_exports.ts new file mode 100644 index 0000000000000..d7e1ff789a949 --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/decorator_rewrite/source_file_exports.ts @@ -0,0 +1,59 @@ +/** + * @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 {getValueSymbolOfDeclaration} from '../../../utils/typescript/symbol'; + +export interface ResolvedExport { + symbol: ts.Symbol; + exportName: string; + identifier: ts.Identifier; +} + +/** Computes the resolved exports of a given source file. */ +export function getExportSymbolsOfFile( + sf: ts.SourceFile, typeChecker: ts.TypeChecker): ResolvedExport[] { + const exports: {exportName: string, identifier: ts.Identifier}[] = []; + const resolvedExports: ResolvedExport[] = []; + + ts.forEachChild(sf, function visitNode(node) { + if (ts.isClassDeclaration(node) || ts.isFunctionDeclaration(node) || + ts.isInterfaceDeclaration(node) && + (ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Export) !== 0) { + if (node.name) { + exports.push({exportName: node.name.text, identifier: node.name}); + } + } else if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + visitNode(decl); + } + } else if (ts.isVariableDeclaration(node)) { + if ((ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) != 0 && + ts.isIdentifier(node.name)) { + exports.push({exportName: node.name.text, identifier: node.name}); + } + } else if (ts.isExportDeclaration(node)) { + const {moduleSpecifier, exportClause} = node; + if (!moduleSpecifier && exportClause) { + exportClause.elements.forEach(el => exports.push({ + exportName: el.name.text, + identifier: el.propertyName ? el.propertyName : el.name + })); + } + } + }); + + exports.forEach(({identifier, exportName}) => { + const symbol = getValueSymbolOfDeclaration(identifier, typeChecker); + if (symbol) { + resolvedExports.push({symbol, identifier, exportName}); + } + }); + + return resolvedExports; +} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/find_base_classes.ts b/packages/core/schematics/migrations/undecorated-classes-with-di/find_base_classes.ts new file mode 100644 index 0000000000000..01467b745d93c --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/find_base_classes.ts @@ -0,0 +1,30 @@ +/** + * @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 {getBaseTypeIdentifiers} from '../../utils/typescript/class_declaration'; + +/** Gets all base class declarations of the specified class declaration. */ +export function findBaseClassDeclarations(node: ts.ClassDeclaration, typeChecker: ts.TypeChecker) { + const result: {identifier: ts.Identifier, node: ts.ClassDeclaration}[] = []; + let currentClass = node; + + while (currentClass) { + const baseTypes = getBaseTypeIdentifiers(currentClass); + if (!baseTypes || baseTypes.length !== 1) { + break; + } + const symbol = typeChecker.getTypeAtLocation(baseTypes[0]).getSymbol(); + if (!symbol || !ts.isClassDeclaration(symbol.valueDeclaration)) { + break; + } + result.push({identifier: baseTypes[0], node: symbol.valueDeclaration}); + currentClass = symbol.valueDeclaration; + } + return result; +} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/import_manager.ts b/packages/core/schematics/migrations/undecorated-classes-with-di/import_manager.ts new file mode 100644 index 0000000000000..8bc46eca1f9ff --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/import_manager.ts @@ -0,0 +1,254 @@ +/** + * @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 {dirname, resolve} from 'path'; +import * as ts from 'typescript'; +import {UpdateRecorder} from './update_recorder'; + +/** + * Import manager that can be used to add TypeScript imports to given source + * files. The manager ensures that multiple transformations are applied properly + * without shifted offsets and that similar existing import declarations are re-used. + */ +export class ImportManager { + /** Map of import declarations that need to be updated to include the given symbols. */ + private updatedImports = + new Map(); + /** Map of source-files and their previously used identifier names. */ + private usedIdentifierNames = new Map(); + /** + * Array of previously resolved symbol imports. Cache can be re-used to return + * the same identifier without checking the source-file again. + */ + private importCache: { + sourceFile: ts.SourceFile, + symbolName: string|null, + moduleName: string, + identifier: ts.Identifier + }[] = []; + + constructor( + private getUpdateRecorder: (sf: ts.SourceFile) => UpdateRecorder, + private printer: ts.Printer) {} + + /** + * Adds an import to the given source-file and returns the TypeScript + * identifier that can be used to access the newly imported symbol. + */ + addImportToSourceFile( + sourceFile: ts.SourceFile, symbolName: string|null, moduleName: string, + typeImport = false): ts.Expression { + const sourceDir = dirname(sourceFile.fileName); + let importStartIndex = 0; + let existingImport: ts.ImportDeclaration|null = null; + + // In case the given import has been already generated previously, we just return + // the previous generated identifier in order to avoid duplicate generated imports. + const cachedImport = this.importCache.find( + c => c.sourceFile === sourceFile && c.symbolName === symbolName && + c.moduleName === moduleName); + if (cachedImport) { + return cachedImport.identifier; + } + + // Walk through all source-file top-level statements and search for import declarations + // that already match the specified "moduleName" and can be updated to import the + // given symbol. If no matching import can be found, the last import in the source-file + // will be used as starting point for a new import that will be generated. + for (let i = sourceFile.statements.length - 1; i >= 0; i--) { + const statement = sourceFile.statements[i]; + + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || + !statement.importClause) { + continue; + } + + if (importStartIndex === 0) { + importStartIndex = this._getEndPositionOfNode(statement); + } + + const moduleSpecifier = statement.moduleSpecifier.text; + + if (moduleSpecifier.startsWith('.') && + resolve(sourceDir, moduleSpecifier) !== resolve(sourceDir, moduleName) || + moduleSpecifier !== moduleName) { + continue; + } + + if (statement.importClause.namedBindings) { + const namedBindings = statement.importClause.namedBindings; + + // In case a "Type" symbol is imported, we can't use namespace imports + // because these only export symbols available at runtime (no types) + if (ts.isNamespaceImport(namedBindings) && !typeImport) { + return ts.createPropertyAccess( + ts.createIdentifier(namedBindings.name.text), + ts.createIdentifier(symbolName || 'default')); + } else if (ts.isNamedImports(namedBindings) && symbolName) { + const existingElement = namedBindings.elements.find( + e => + e.propertyName ? e.propertyName.text === symbolName : e.name.text === symbolName); + + if (existingElement) { + return ts.createIdentifier(existingElement.name.text); + } + + // In case the symbol could not be found in an existing import, we + // keep track of the import declaration as it can be updated to include + // the specified symbol name without having to create a new import. + existingImport = statement; + } + } else if (statement.importClause.name && !symbolName) { + return ts.createIdentifier(statement.importClause.name.text); + } + } + + if (existingImport) { + const propertyIdentifier = ts.createIdentifier(symbolName !); + const generatedUniqueIdentifier = this._getUniqueIdentifier(sourceFile, symbolName !); + const needsGeneratedUniqueName = generatedUniqueIdentifier.text !== symbolName; + const importName = needsGeneratedUniqueName ? generatedUniqueIdentifier : propertyIdentifier; + + // Since it can happen that multiple classes need to be imported within the + // specified source file and we want to add the identifiers to the existing + // import declaration, we need to keep track of the updated import declarations. + // We can't directly update the import declaration for each identifier as this + // would throw off the recorder offsets. We need to keep track of the new identifiers + // for the import and perform the import transformation as batches per source-file. + this.updatedImports.set( + existingImport, (this.updatedImports.get(existingImport) || []).concat({ + propertyName: needsGeneratedUniqueName ? propertyIdentifier : undefined, + importName: importName, + })); + + // Keep track of all updated imports so that we don't generate duplicate + // similar imports as these can't be statically analyzed in the source-file yet. + this.importCache.push({sourceFile, moduleName, symbolName, identifier: importName}); + + return importName; + } + + let identifier: ts.Identifier|null = null; + let newImport: ts.ImportDeclaration|null = null; + + if (symbolName) { + const propertyIdentifier = ts.createIdentifier(symbolName); + const generatedUniqueIdentifier = this._getUniqueIdentifier(sourceFile, symbolName); + const needsGeneratedUniqueName = generatedUniqueIdentifier.text !== symbolName; + identifier = needsGeneratedUniqueName ? generatedUniqueIdentifier : propertyIdentifier; + + newImport = ts.createImportDeclaration( + undefined, undefined, + ts.createImportClause( + undefined, + ts.createNamedImports([ts.createImportSpecifier( + needsGeneratedUniqueName ? propertyIdentifier : undefined, identifier)])), + ts.createStringLiteral(moduleName)); + } else { + identifier = this._getUniqueIdentifier(sourceFile, 'defaultExport'); + newImport = ts.createImportDeclaration( + undefined, undefined, ts.createImportClause(identifier, undefined), + ts.createStringLiteral(moduleName)); + } + + const newImportText = this.printer.printNode(ts.EmitHint.Unspecified, newImport, sourceFile); + // If the import is generated at the start of the source file, we want to add + // a new-line after the import. Otherwise if the import is generated after an + // existing import, we need to prepend a new-line so that the import is not on + // the same line as the existing import anchor. + this.getUpdateRecorder(sourceFile) + .addNewImport( + importStartIndex, importStartIndex === 0 ? `${newImportText}\n` : `\n${newImportText}`); + + // Keep track of all generated imports so that we don't generate duplicate + // similar imports as these can't be statically analyzed in the source-file yet. + this.importCache.push({sourceFile, symbolName, moduleName, identifier}); + + return identifier; + } + + /** + * Stores the collected import changes within the appropriate update recorders. The + * updated imports can only be updated *once* per source-file because previous updates + * could otherwise shift the source-file offsets. + */ + recordChanges() { + this.updatedImports.forEach((expressions, importDecl) => { + const sourceFile = importDecl.getSourceFile(); + const recorder = this.getUpdateRecorder(sourceFile); + const namedBindings = importDecl.importClause !.namedBindings as ts.NamedImports; + const newNamedBindings = ts.updateNamedImports( + namedBindings, + namedBindings.elements.concat(expressions.map( + ({propertyName, importName}) => ts.createImportSpecifier(propertyName, importName)))); + + const newNamedBindingsText = + this.printer.printNode(ts.EmitHint.Unspecified, newNamedBindings, sourceFile); + recorder.updateExistingImport(namedBindings, newNamedBindingsText); + }); + } + + /** Gets an unique identifier with a base name for the given source file. */ + private _getUniqueIdentifier(sourceFile: ts.SourceFile, baseName: string): ts.Identifier { + if (this.isUniqueIdentifierName(sourceFile, baseName)) { + this._recordUsedIdentifier(sourceFile, baseName); + return ts.createIdentifier(baseName); + } + + let name = null; + let counter = 1; + do { + name = `${baseName}_${counter++}`; + } while (!this.isUniqueIdentifierName(sourceFile, name)); + + this._recordUsedIdentifier(sourceFile, name !); + return ts.createIdentifier(name !); + } + + /** + * Checks whether the specified identifier name is used within the given + * source file. + */ + private isUniqueIdentifierName(sourceFile: ts.SourceFile, name: string) { + if (this.usedIdentifierNames.has(sourceFile) && + this.usedIdentifierNames.get(sourceFile) !.indexOf(name) !== -1) { + return false; + } + + // Walk through the source file and search for an identifier matching + // the given name. In that case, it's not guaranteed that this name + // is unique in the given declaration scope and we just return false. + const nodeQueue: ts.Node[] = [sourceFile]; + while (nodeQueue.length) { + const node = nodeQueue.shift() !; + if (ts.isIdentifier(node) && node.text === name) { + return false; + } + nodeQueue.push(...node.getChildren()); + } + return true; + } + + private _recordUsedIdentifier(sourceFile: ts.SourceFile, identifierName: string) { + this.usedIdentifierNames.set( + sourceFile, (this.usedIdentifierNames.get(sourceFile) || []).concat(identifierName)); + } + + /** + * Determines the full end of a given node. By default the end position of a node is + * before all trailing comments. This could mean that generated imports shift comments. + */ + private _getEndPositionOfNode(node: ts.Node) { + const nodeEndPos = node.getEnd(); + const commentRanges = ts.getTrailingCommentRanges(node.getSourceFile().text, nodeEndPos); + if (!commentRanges || !commentRanges.length) { + return nodeEndPos; + } + return commentRanges[commentRanges.length - 1] !.end; + } +} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/index.ts b/packages/core/schematics/migrations/undecorated-classes-with-di/index.ts new file mode 100644 index 0000000000000..3689f1aa9a27b --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/index.ts @@ -0,0 +1,190 @@ +/** + * @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 {logging} from '@angular-devkit/core'; +import {Rule, SchematicContext, SchematicsException, Tree} from '@angular-devkit/schematics'; +import {AotCompiler} from '@angular/compiler'; +import {PartialEvaluator} from '@angular/compiler-cli/src/ngtsc/partial_evaluator'; +import {TypeScriptReflectionHost} from '@angular/compiler-cli/src/ngtsc/reflection'; +import {relative} from 'path'; +import * as ts from 'typescript'; + +import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; + +import {createNgcProgram} from './create_ngc_program'; +import {NgDeclarationCollector} from './ng_declaration_collector'; +import {UndecoratedClassesTransform} from './transform'; +import {UpdateRecorder} from './update_recorder'; + +const MIGRATION_RERUN_MESSAGE = 'Migration can be rerun with: "ng update @angular/core ' + + '--from 8.0.0 --to 9.0.0 --migrate-only"'; + +const MIGRATION_AOT_FAILURE = 'This migration uses the Angular compiler internally and ' + + 'therefore projects that no longer build successfully after the update cannot run ' + + 'the migration. Please ensure there are no AOT compilation errors and rerun the migration.'; + +/** Entry point for the V9 "undecorated-classes-with-di" migration. */ +export default function(): Rule { + return (tree: Tree, ctx: SchematicContext) => { + const {buildPaths} = getProjectTsConfigPaths(tree); + const basePath = process.cwd(); + const failures: string[] = []; + + ctx.logger.info('------ Undecorated classes with DI migration ------'); + + if (!buildPaths.length) { + throw new SchematicsException( + 'Could not find any tsconfig file. Cannot migrate undecorated derived classes and ' + + 'undecorated base classes which use DI.'); + } + + for (const tsconfigPath of buildPaths) { + failures.push(...runUndecoratedClassesMigration(tree, tsconfigPath, basePath, ctx.logger)); + } + + if (failures.length) { + ctx.logger.info('Could not migrate all undecorated classes that use dependency'); + ctx.logger.info('injection. Please manually fix the following failures:'); + failures.forEach(message => ctx.logger.warn(`⮑ ${message}`)); + } else { + ctx.logger.info('Successfully migrated all found undecorated classes'); + ctx.logger.info('that use dependency injection.'); + } + + ctx.logger.info('----------------------------------------------'); + }; +} + +function runUndecoratedClassesMigration( + tree: Tree, tsconfigPath: string, basePath: string, logger: logging.LoggerApi): string[] { + const failures: string[] = []; + const programData = gracefullyCreateProgram(tree, basePath, tsconfigPath, logger); + + // Gracefully exit if the program could not be created. + if (programData === null) { + return []; + } + + const {program, compiler} = programData; + const typeChecker = program.getTypeChecker(); + const partialEvaluator = + new PartialEvaluator(new TypeScriptReflectionHost(typeChecker), typeChecker); + const declarationCollector = new NgDeclarationCollector(typeChecker, partialEvaluator); + const rootSourceFiles = program.getRootFileNames().map(f => program.getSourceFile(f) !); + + // Analyze source files by detecting all directives, components and providers. + rootSourceFiles.forEach(sourceFile => declarationCollector.visitNode(sourceFile)); + + const {decoratedDirectives, decoratedProviders, undecoratedDeclarations} = declarationCollector; + const transform = + new UndecoratedClassesTransform(typeChecker, compiler, partialEvaluator, getUpdateRecorder); + const updateRecorders = new Map(); + + // Run the migrations for decorated providers and both decorated and undecorated + // directives. The transform failures are collected and converted into human-readable + // failures which can be printed to the console. + [...transform.migrateDecoratedDirectives(decoratedDirectives), + ...transform.migrateDecoratedProviders(decoratedProviders), + ...transform.migrateUndecoratedDeclarations(Array.from(undecoratedDeclarations))] + .forEach(({node, message}) => { + const nodeSourceFile = node.getSourceFile(); + const relativeFilePath = relative(basePath, nodeSourceFile.fileName); + const {line, character} = + ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()); + failures.push(`${relativeFilePath}@${line + 1}:${character + 1}: ${message}`); + }); + + // Record the changes collected in the import manager and transformer. + transform.recordChanges(); + + // Walk through each update recorder and commit the update. We need to commit the + // updates in batches per source file as there can be only one recorder per source + // file in order to avoid shifted character offsets. + updateRecorders.forEach(recorder => recorder.commitUpdate()); + + return failures; + + /** Gets the update recorder for the specified source file. */ + function getUpdateRecorder(sourceFile: ts.SourceFile): UpdateRecorder { + if (updateRecorders.has(sourceFile)) { + return updateRecorders.get(sourceFile) !; + } + const treeRecorder = tree.beginUpdate(relative(basePath, sourceFile.fileName)); + const recorder: UpdateRecorder = { + addClassComment(node: ts.ClassDeclaration, text: string) { + treeRecorder.insertLeft(node.members.pos, `\n // ${text}\n`); + }, + addClassDecorator(node: ts.ClassDeclaration, text: string) { + // New imports should be inserted at the left while decorators should be inserted + // at the right in order to ensure that imports are inserted before the decorator + // if the start position of import and decorator is the source file start. + treeRecorder.insertRight(node.getStart(), `${text}\n`); + }, + addNewImport(start: number, importText: string) { + // New imports should be inserted at the left while decorators should be inserted + // at the right in order to ensure that imports are inserted before the decorator + // if the start position of import and decorator is the source file start. + treeRecorder.insertLeft(start, importText); + }, + updateExistingImport(namedBindings: ts.NamedImports, newNamedBindings: string) { + treeRecorder.remove(namedBindings.getStart(), namedBindings.getWidth()); + treeRecorder.insertRight(namedBindings.getStart(), newNamedBindings); + }, + commitUpdate() { tree.commitUpdate(treeRecorder); } + }; + updateRecorders.set(sourceFile, recorder); + return recorder; + } +} + +function gracefullyCreateProgram( + tree: Tree, basePath: string, tsconfigPath: string, + logger: logging.LoggerApi): {compiler: AotCompiler, program: ts.Program}|null { + try { + const {ngcProgram, host, program, compiler} = createNgcProgram((options) => { + const host = ts.createCompilerHost(options, true); + + // We need to overwrite the host "readFile" method, as we want the TypeScript + // program to be based on the file contents in the virtual file tree. + host.readFile = fileName => { + const buffer = tree.read(relative(basePath, fileName)); + // Strip BOM as otherwise TSC methods (Ex: getWidth) will return an offset which + // which breaks the CLI UpdateRecorder. + // See: https://github.com/angular/angular/pull/30719 + return buffer ? buffer.toString().replace(/^\uFEFF/, '') : undefined; + }; + + return host; + }, tsconfigPath); + const syntacticDiagnostics = ngcProgram.getTsSyntacticDiagnostics(); + const structuralDiagnostics = ngcProgram.getNgStructuralDiagnostics(); + + // Syntactic TypeScript errors can throw off the query analysis and therefore we want + // to notify the developer that we couldn't analyze parts of the project. Developers + // can just re-run the migration after fixing these failures. + if (syntacticDiagnostics.length) { + logger.warn( + `\nTypeScript project "${tsconfigPath}" has syntactical errors which could cause ` + + `an incomplete migration. Please fix the following failures and rerun the migration:`); + logger.error(ts.formatDiagnostics(syntacticDiagnostics, host)); + logger.info(MIGRATION_RERUN_MESSAGE); + return null; + } + + if (structuralDiagnostics.length) { + throw new Error(ts.formatDiagnostics(structuralDiagnostics, host)); + } + + return {program, compiler}; + } catch (e) { + logger.warn(`\n${MIGRATION_AOT_FAILURE}. The following project failed: ${tsconfigPath}\n`); + logger.error(`${e.toString()}\n`); + logger.info(MIGRATION_RERUN_MESSAGE); + return null; + } +} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/ng_declaration_collector.ts b/packages/core/schematics/migrations/undecorated-classes-with-di/ng_declaration_collector.ts new file mode 100644 index 0000000000000..f0c770cbda18b --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/ng_declaration_collector.ts @@ -0,0 +1,147 @@ +/** + * @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 {Reference} from '@angular/compiler-cli/src/ngtsc/imports'; +import {PartialEvaluator, ResolvedValue} from '@angular/compiler-cli/src/ngtsc/partial_evaluator'; +import * as ts from 'typescript'; + +import {NgDecorator, getAngularDecorators} from '../../utils/ng_decorators'; +import {getPropertyNameText} from '../../utils/typescript/property_name'; + + +/** + * Visitor that walks through specified TypeScript nodes and collects all defined + * directives and provider classes. Directives are separated by decorated and + * undecorated directives. + */ +export class NgDeclarationCollector { + /** List of resolved directives which are decorated. */ + decoratedDirectives: ts.ClassDeclaration[] = []; + + /** List of resolved providers which are decorated. */ + decoratedProviders: ts.ClassDeclaration[] = []; + + /** Set of resolved Angular declarations which are not decorated. */ + undecoratedDeclarations = new Set(); + + constructor(public typeChecker: ts.TypeChecker, private evaluator: PartialEvaluator) {} + + visitNode(node: ts.Node) { + if (ts.isClassDeclaration(node)) { + this._visitClassDeclaration(node); + } + + ts.forEachChild(node, n => this.visitNode(n)); + } + + private _visitClassDeclaration(node: ts.ClassDeclaration) { + if (!node.decorators || !node.decorators.length) { + return; + } + + const ngDecorators = getAngularDecorators(this.typeChecker, node.decorators); + const ngModuleDecorator = ngDecorators.find(({name}) => name === 'NgModule'); + + if (hasDirectiveDecorator(node, this.typeChecker, ngDecorators)) { + this.decoratedDirectives.push(node); + } else if (hasInjectableDecorator(node, this.typeChecker, ngDecorators)) { + this.decoratedProviders.push(node); + } else if (ngModuleDecorator) { + this._visitNgModuleDecorator(ngModuleDecorator); + } + } + + private _visitNgModuleDecorator(decorator: NgDecorator) { + const decoratorCall = decorator.node.expression; + const metadata = decoratorCall.arguments[0]; + + if (!metadata || !ts.isObjectLiteralExpression(metadata)) { + return; + } + + let entryComponentsNode: ts.Expression|null = null; + let declarationsNode: ts.Expression|null = null; + + metadata.properties.forEach(p => { + if (!ts.isPropertyAssignment(p)) { + return; + } + + const name = getPropertyNameText(p.name); + + if (name === 'entryComponents') { + entryComponentsNode = p.initializer; + } else if (name === 'declarations') { + declarationsNode = p.initializer; + } + }); + + // In case the module specifies the "entryComponents" field, walk through all + // resolved entry components and collect the referenced directives. + if (entryComponentsNode) { + flattenTypeList(this.evaluator.evaluate(entryComponentsNode)).forEach(ref => { + if (ts.isClassDeclaration(ref.node) && + !hasNgDeclarationDecorator(ref.node, this.typeChecker)) { + this.undecoratedDeclarations.add(ref.node); + } + }); + } + + // In case the module specifies the "declarations" field, walk through all + // resolved declarations and collect the referenced directives. + if (declarationsNode) { + flattenTypeList(this.evaluator.evaluate(declarationsNode)).forEach(ref => { + if (ts.isClassDeclaration(ref.node) && + !hasNgDeclarationDecorator(ref.node, this.typeChecker)) { + this.undecoratedDeclarations.add(ref.node); + } + }); + } + } +} + +/** Flattens a list of type references. */ +function flattenTypeList(value: ResolvedValue): Reference[] { + if (Array.isArray(value)) { + return value.reduce( + (res: Reference[], v: ResolvedValue) => res.concat(flattenTypeList(v)), []); + } else if (value instanceof Reference) { + return [value]; + } + return []; +} + +/** Checks whether the given node has the "@Directive" or "@Component" decorator set. */ +export function hasDirectiveDecorator( + node: ts.ClassDeclaration, typeChecker: ts.TypeChecker, ngDecorators?: NgDecorator[]): boolean { + return (ngDecorators || getNgClassDecorators(node, typeChecker)) + .some(({name}) => name === 'Directive' || name === 'Component'); +} + + + +/** Checks whether the given node has the "@Injectable" decorator set. */ +export function hasInjectableDecorator( + node: ts.ClassDeclaration, typeChecker: ts.TypeChecker, ngDecorators?: NgDecorator[]): boolean { + return (ngDecorators || getNgClassDecorators(node, typeChecker)) + .some(({name}) => name === 'Injectable'); +} +/** Whether the given node has an explicit decorator that describes an Angular declaration. */ +export function hasNgDeclarationDecorator(node: ts.ClassDeclaration, typeChecker: ts.TypeChecker) { + return getNgClassDecorators(node, typeChecker) + .some(({name}) => name === 'Component' || name === 'Directive' || name === 'Pipe'); +} + +/** Gets all Angular decorators of a given class declaration. */ +export function getNgClassDecorators( + node: ts.ClassDeclaration, typeChecker: ts.TypeChecker): NgDecorator[] { + if (!node.decorators) { + return []; + } + return getAngularDecorators(typeChecker, node.decorators); +} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/transform.ts b/packages/core/schematics/migrations/undecorated-classes-with-di/transform.ts new file mode 100644 index 0000000000000..54e46f964e92f --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/transform.ts @@ -0,0 +1,491 @@ +/** + * @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 {AotCompiler, AotCompilerHost, CompileMetadataResolver, StaticSymbol, StaticSymbolResolver, SummaryResolver} from '@angular/compiler'; +import {PartialEvaluator} from '@angular/compiler-cli/src/ngtsc/partial_evaluator'; +import {ChangeDetectionStrategy, ViewEncapsulation} from '@angular/core'; +import * as ts from 'typescript'; + +import {getAngularDecorators} from '../../utils/ng_decorators'; +import {hasExplicitConstructor} from '../../utils/typescript/class_declaration'; +import {getImportOfIdentifier} from '../../utils/typescript/imports'; + +import {UnexpectedMetadataValueError, convertDirectiveMetadataToExpression} from './decorator_rewrite/convert_directive_metadata'; +import {DecoratorRewriter} from './decorator_rewrite/decorator_rewriter'; +import {findBaseClassDeclarations} from './find_base_classes'; +import {ImportManager} from './import_manager'; +import {hasDirectiveDecorator, hasInjectableDecorator} from './ng_declaration_collector'; +import {UpdateRecorder} from './update_recorder'; + + + +/** Resolved metadata of a declaration. */ +interface DeclarationMetadata { + metadata: any; + type: 'Component'|'Directive'|'Pipe'; +} + +export interface TransformFailure { + node: ts.Node; + message: string; +} + +export class UndecoratedClassesTransform { + private printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed}); + private importManager = new ImportManager(this.getUpdateRecorder, this.printer); + private decoratorRewriter = + new DecoratorRewriter(this.importManager, this.typeChecker, this.evaluator, this.compiler); + + private compilerHost: AotCompilerHost; + private symbolResolver: StaticSymbolResolver; + private metadataResolver: CompileMetadataResolver; + + /** Set of class declarations which have been decorated with "@Directive". */ + private decoratedDirectives = new Set(); + /** Set of class declarations which have been decorated with "@Injectable" */ + private decoratedProviders = new Set(); + /** + * Set of class declarations which have been analyzed and need to specify + * an explicit constructor. + */ + private missingExplicitConstructorClasses = new Set(); + + constructor( + private typeChecker: ts.TypeChecker, private compiler: AotCompiler, + private evaluator: PartialEvaluator, + private getUpdateRecorder: (sf: ts.SourceFile) => UpdateRecorder) { + this.symbolResolver = compiler['_symbolResolver']; + this.compilerHost = compiler['_host']; + this.metadataResolver = compiler['_metadataResolver']; + + // Unset the default error recorder so that the reflector will throw an exception + // if metadata cannot be resolved. + this.compiler.reflector['errorRecorder'] = undefined; + + // Disables that static symbols are resolved through summaries from within the static + // reflector. Summaries cannot be used for decorator serialization as decorators are + // omitted in summaries and the decorator can't be reconstructed from the directive summary. + this._disableSummaryResolution(); + } + + /** + * Migrates decorated directives which can potentially inherit a constructor + * from an undecorated base class. All base classes until the first one + * with an explicit constructor will be decorated with the abstract "@Directive()" + * decorator. See case 1 in the migration plan: https://hackmd.io/@alx/S1XKqMZeS + */ + migrateDecoratedDirectives(directives: ts.ClassDeclaration[]): TransformFailure[] { + return directives.reduce( + (failures, node) => failures.concat(this._migrateDirectiveBaseClass(node)), + [] as TransformFailure[]); + } + + /** + * Migrates decorated providers which can potentially inherit a constructor + * from an undecorated base class. All base classes until the first one + * with an explicit constructor will be decorated with the "@Injectable()". + */ + migrateDecoratedProviders(providers: ts.ClassDeclaration[]): TransformFailure[] { + return providers.reduce( + (failures, node) => failures.concat(this._migrateProviderBaseClass(node)), + [] as TransformFailure[]); + } + + private _migrateProviderBaseClass(node: ts.ClassDeclaration): TransformFailure[] { + // In case the provider has an explicit constructor, we don't need to do anything + // because the class is already decorated and does not inherit a constructor. + if (hasExplicitConstructor(node)) { + return []; + } + + const orderedBaseClasses = findBaseClassDeclarations(node, this.typeChecker); + let lastDecoratedClass: ts.ClassDeclaration = node; + + for (let {node: baseClass, identifier} of orderedBaseClasses) { + const baseClassFile = baseClass.getSourceFile(); + + if (hasExplicitConstructor(baseClass)) { + if (baseClassFile.isDeclarationFile) { + const staticSymbol = this._getStaticSymbolOfIdentifier(identifier); + + // If the base class is decorated through metadata files, we don't + // need to add a comment to the derived class for the external base class. + if (staticSymbol && this.metadataResolver.isInjectable(staticSymbol)) { + break; + } + + // If the base class is not decorated, we cannot decorate the base class and + // need to a comment to the last decorated class. + return this._addMissingExplicitConstructorTodo(lastDecoratedClass); + } + + this._addInjectableDecorator(baseClass); + break; + } + + // Add the "@Injectable" decorator for all base classes in the inheritance chain + // until the base class with the explicit constructor. The decorator will be only + // added for base classes which can be modified. + if (!baseClassFile.isDeclarationFile) { + this._addInjectableDecorator(baseClass); + lastDecoratedClass = baseClass; + } + } + return []; + } + + private _migrateDirectiveBaseClass(node: ts.ClassDeclaration): TransformFailure[] { + // In case the directive has an explicit constructor, we don't need to do + // anything because the class is already decorated with "@Directive" or "@Component" + if (hasExplicitConstructor(node)) { + return []; + } + + const orderedBaseClasses = findBaseClassDeclarations(node, this.typeChecker); + let lastDecoratedClass: ts.ClassDeclaration = node; + + for (let {node: baseClass, identifier} of orderedBaseClasses) { + const baseClassFile = baseClass.getSourceFile(); + + if (hasExplicitConstructor(baseClass)) { + if (baseClassFile.isDeclarationFile) { + // If the base class is decorated through metadata files, we don't + // need to add a comment to the derived class for the external base class. + if (this._hasDirectiveMetadata(identifier)) { + break; + } + + // If the base class is not decorated, we cannot decorate the base class and + // need to a comment to the last decorated class. + return this._addMissingExplicitConstructorTodo(lastDecoratedClass); + } + + this._addAbstractDirectiveDecorator(baseClass); + break; + } + + // Add the abstract directive decorator for all base classes in the inheritance + // chain until the base class with the explicit constructor. The decorator will + // be only added for base classes which can be modified. + if (!baseClassFile.isDeclarationFile) { + this._addAbstractDirectiveDecorator(baseClass); + lastDecoratedClass = baseClass; + } + } + return []; + } + + /** + * Adds the abstract "@Directive()" decorator to the given class in case there + * is no existing directive decorator. + */ + private _addAbstractDirectiveDecorator(baseClass: ts.ClassDeclaration) { + if (hasDirectiveDecorator(baseClass, this.typeChecker) || + this.decoratedDirectives.has(baseClass)) { + return; + } + + const baseClassFile = baseClass.getSourceFile(); + const recorder = this.getUpdateRecorder(baseClassFile); + const directiveExpr = + this.importManager.addImportToSourceFile(baseClassFile, 'Directive', '@angular/core'); + + const newDecorator = ts.createDecorator(ts.createCall(directiveExpr, undefined, [])); + const newDecoratorText = + this.printer.printNode(ts.EmitHint.Unspecified, newDecorator, baseClassFile); + + recorder.addClassDecorator(baseClass, newDecoratorText); + this.decoratedDirectives.add(baseClass); + } + + /** + * Adds the abstract "@Injectable()" decorator to the given class in case there + * is no existing directive decorator. + */ + private _addInjectableDecorator(baseClass: ts.ClassDeclaration) { + if (hasInjectableDecorator(baseClass, this.typeChecker) || + this.decoratedProviders.has(baseClass)) { + return; + } + + const baseClassFile = baseClass.getSourceFile(); + const recorder = this.getUpdateRecorder(baseClassFile); + const injectableExpr = + this.importManager.addImportToSourceFile(baseClassFile, 'Injectable', '@angular/core'); + + const newDecorator = ts.createDecorator(ts.createCall(injectableExpr, undefined, [])); + const newDecoratorText = + this.printer.printNode(ts.EmitHint.Unspecified, newDecorator, baseClassFile); + + recorder.addClassDecorator(baseClass, newDecoratorText); + this.decoratedProviders.add(baseClass); + } + + /** Adds a comment for adding an explicit constructor to the given class declaration. */ + private _addMissingExplicitConstructorTodo(node: ts.ClassDeclaration): TransformFailure[] { + // In case a todo comment has been already inserted to the given class, we don't + // want to add a comment or transform failure multiple times. + if (this.missingExplicitConstructorClasses.has(node)) { + return []; + } + this.missingExplicitConstructorClasses.add(node); + const recorder = this.getUpdateRecorder(node.getSourceFile()); + recorder.addClassComment(node, 'TODO: add explicit constructor'); + return [{node: node, message: 'Class needs to declare an explicit constructor.'}]; + } + + /** + * Migrates undecorated directives which were referenced in NgModule declarations. + * These directives inherit the metadata from a parent base class, but with Ivy + * these classes need to explicitly have a decorator for locality. The migration + * determines the inherited decorator and copies it to the undecorated declaration. + * + * Note that the migration serializes the metadata for external declarations + * where the decorator is not part of the source file AST. + * + * See case 2 in the migration plan: https://hackmd.io/@alx/S1XKqMZeS + */ + migrateUndecoratedDeclarations(directives: ts.ClassDeclaration[]): TransformFailure[] { + return directives.reduce( + (failures, node) => failures.concat(this._migrateDerivedDeclaration(node)), + [] as TransformFailure[]); + } + + private _migrateDerivedDeclaration(node: ts.ClassDeclaration): TransformFailure[] { + const targetSourceFile = node.getSourceFile(); + const orderedBaseClasses = findBaseClassDeclarations(node, this.typeChecker); + let newDecoratorText: string|null = null; + + for (let {node: baseClass, identifier} of orderedBaseClasses) { + // Before looking for decorators within the metadata or summary files, we + // try to determine the directive decorator through the source file AST. + if (baseClass.decorators) { + const ngDecorator = + getAngularDecorators(this.typeChecker, baseClass.decorators) + .find(({name}) => name === 'Component' || name === 'Directive' || name === 'Pipe'); + + if (ngDecorator) { + const newDecorator = this.decoratorRewriter.rewrite(ngDecorator, node.getSourceFile()); + newDecoratorText = this.printer.printNode( + ts.EmitHint.Unspecified, newDecorator, ngDecorator.node.getSourceFile()); + break; + } + } + + // If no metadata could be found within the source-file AST, try to find + // decorator data through Angular metadata and summary files. + const staticSymbol = this._getStaticSymbolOfIdentifier(identifier); + + // Check if the static symbol resolves to a class declaration with + // pipe or directive metadata. + if (!staticSymbol || + !(this.metadataResolver.isPipe(staticSymbol) || + this.metadataResolver.isDirective(staticSymbol))) { + continue; + } + + const metadata = this._resolveDeclarationMetadata(staticSymbol); + + // If no metadata could be resolved for the static symbol, print a failure message + // and ask the developer to manually migrate the class. This case is rare because + // usually decorator metadata is always present but just can't be read if a program + // only has access to summaries (this is a special case in google3). + if (!metadata) { + return [{ + node, + message: `Class cannot be migrated as the inherited metadata from ` + + `${identifier.getText()} cannot be converted into a decorator. Please manually + decorate the class.`, + }]; + } + + const newDecorator = this._constructDecoratorFromMetadata(metadata, targetSourceFile); + if (!newDecorator) { + const annotationType = metadata.type; + return [{ + node, + message: `Class cannot be migrated as the inherited @${annotationType} decorator ` + + `cannot be copied. Please manually add a @${annotationType} decorator.`, + }]; + } + + // In case the decorator could be constructed from the resolved metadata, use + // that decorator for the derived undecorated classes. + newDecoratorText = + this.printer.printNode(ts.EmitHint.Unspecified, newDecorator, targetSourceFile); + break; + } + + if (!newDecoratorText) { + return [{ + node, + message: + 'Class cannot be migrated as no directive/component/pipe metadata could be found. ' + + 'Please manually add a @Directive, @Component or @Pipe decorator.' + }]; + } + + this.getUpdateRecorder(targetSourceFile).addClassDecorator(node, newDecoratorText); + return []; + } + + /** Records all changes that were made in the import manager. */ + recordChanges() { this.importManager.recordChanges(); } + + /** + * Constructs a TypeScript decorator node from the specified declaration metadata. Returns + * null if the metadata could not be simplified/resolved. + */ + private _constructDecoratorFromMetadata( + directiveMetadata: DeclarationMetadata, targetSourceFile: ts.SourceFile): ts.Decorator|null { + try { + const decoratorExpr = convertDirectiveMetadataToExpression( + directiveMetadata.metadata, + staticSymbol => + this.compilerHost + .fileNameToModuleName(staticSymbol.filePath, targetSourceFile.fileName) + .replace(/\/index$/, ''), + (moduleName: string, name: string) => + this.importManager.addImportToSourceFile(targetSourceFile, name, moduleName), + (propertyName, value) => { + // Only normalize properties called "changeDetection" and "encapsulation" + // for "@Directive" and "@Component" annotations. + if (directiveMetadata.type === 'Pipe') { + return null; + } + + // Instead of using the number as value for the "changeDetection" and + // "encapsulation" properties, we want to use the actual enum symbols. + if (propertyName === 'changeDetection' && typeof value === 'number') { + return ts.createPropertyAccess( + this.importManager.addImportToSourceFile( + targetSourceFile, 'ChangeDetectionStrategy', '@angular/core'), + ChangeDetectionStrategy[value]); + } else if (propertyName === 'encapsulation' && typeof value === 'number') { + return ts.createPropertyAccess( + this.importManager.addImportToSourceFile( + targetSourceFile, 'ViewEncapsulation', '@angular/core'), + ViewEncapsulation[value]); + } + return null; + }); + + return ts.createDecorator(ts.createCall( + this.importManager.addImportToSourceFile( + targetSourceFile, directiveMetadata.type, '@angular/core'), + undefined, [decoratorExpr])); + } catch (e) { + if (e instanceof UnexpectedMetadataValueError) { + return null; + } + throw e; + } + } + + /** + * Whether the given identifier resolves to a class declaration that + * has metadata for a directive. + */ + private _hasDirectiveMetadata(node: ts.Identifier): boolean { + const symbol = this._getStaticSymbolOfIdentifier(node); + + if (!symbol) { + return false; + } + + return this.metadataResolver.isDirective(symbol); + } + + /** + * Resolves the declaration metadata of a given static symbol. The metadata + * is determined by resolving metadata for the static symbol. + */ + private _resolveDeclarationMetadata(symbol: StaticSymbol): null|DeclarationMetadata { + try { + // Note that this call can throw if the metadata is not computable. In that + // case we are not able to serialize the metadata into a decorator and we return + // null. + const annotations = this.compiler.reflector.annotations(symbol).find( + s => s.ngMetadataName === 'Component' || s.ngMetadataName === 'Directive' || + s.ngMetadataName === 'Pipe'); + + if (!annotations) { + return null; + } + + const {ngMetadataName, ...metadata} = annotations; + + // Delete the "ngMetadataName" property as we don't want to generate + // a property assignment in the new decorator for that internal property. + delete metadata['ngMetadataName']; + + return {type: ngMetadataName, metadata}; + } catch (e) { + return null; + } + } + + private _getStaticSymbolOfIdentifier(node: ts.Identifier): StaticSymbol|null { + const sourceFile = node.getSourceFile(); + const resolvedImport = getImportOfIdentifier(this.typeChecker, node); + + if (!resolvedImport) { + return null; + } + + const moduleName = + this.compilerHost.moduleNameToFileName(resolvedImport.importModule, sourceFile.fileName); + + if (!moduleName) { + return null; + } + + // Find the declaration symbol as symbols could be aliased due to + // metadata re-exports. + return this.compiler.reflector.findSymbolDeclaration( + this.symbolResolver.getStaticSymbol(moduleName, resolvedImport.name)); + } + + /** + * Disables that static symbols are resolved through summaries. Summaries + * cannot be used for decorator analysis as decorators are omitted in summaries. + */ + private _disableSummaryResolution() { + // We never want to resolve symbols through summaries. Summaries never contain + // decorators for class symbols and therefore summaries will cause every class + // to be considered as undecorated. See reason for this in: "ToJsonSerializer". + // In order to ensure that metadata is not retrieved through summaries, we + // need to disable summary resolution, clear previous symbol caches. This way + // future calls to "StaticReflector#annotations" are based on metadata files. + this.symbolResolver['_resolveSymbolFromSummary'] = () => null; + this.symbolResolver['resolvedSymbols'].clear(); + this.symbolResolver['resolvedFilePaths'].clear(); + this.compiler.reflector['annotationCache'].clear(); + + // Original summary resolver used by the AOT compiler. + const summaryResolver = this.symbolResolver['summaryResolver']; + + // Additionally we need to ensure that no files are treated as "library" files when + // resolving metadata. This is necessary because by default the symbol resolver discards + // class metadata for library files. See "StaticSymbolResolver#createResolvedSymbol". + // Patching this function **only** for the static symbol resolver ensures that metadata + // is not incorrectly omitted. Note that we only want to do this for the symbol resolver + // because otherwise we could break the summary loading logic which is used to detect + // if a static symbol is either a directive, component or pipe (see MetadataResolver). + this.symbolResolver['summaryResolver'] = >{ + fromSummaryFileName: summaryResolver.fromSummaryFileName.bind(summaryResolver), + addSummary: summaryResolver.addSummary.bind(summaryResolver), + getImportAs: summaryResolver.getImportAs.bind(summaryResolver), + getKnownModuleName: summaryResolver.getKnownModuleName.bind(summaryResolver), + resolveSummary: summaryResolver.resolveSummary.bind(summaryResolver), + toSummaryFileName: summaryResolver.toSummaryFileName.bind(summaryResolver), + getSymbolsOf: summaryResolver.getSymbolsOf.bind(summaryResolver), + isLibraryFile: () => false, + }; + } +} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-di/update_recorder.ts b/packages/core/schematics/migrations/undecorated-classes-with-di/update_recorder.ts new file mode 100644 index 0000000000000..baa341ecce2f8 --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-di/update_recorder.ts @@ -0,0 +1,23 @@ + +/** + * @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'; + +/** + * Update recorder interface that is used to transform source files in a non-colliding + * way. Also this indirection makes it possible to re-use transformation logic with + * different replacement tools (e.g. TSLint or CLI devkit). + */ +export interface UpdateRecorder { + addNewImport(start: number, importText: string): void; + updateExistingImport(namedBindings: ts.NamedImports, newNamedBindings: string): void; + addClassDecorator(node: ts.ClassDeclaration, text: string): void; + addClassComment(node: ts.ClassDeclaration, text: string): void; + commitUpdate(): void; +} diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel index 421d825526e50..3c5ad405f6d32 100644 --- a/packages/core/schematics/test/BUILD.bazel +++ b/packages/core/schematics/test/BUILD.bazel @@ -15,6 +15,7 @@ ts_library( "//packages/core/schematics/migrations/renderer-to-renderer2", "//packages/core/schematics/migrations/static-queries", "//packages/core/schematics/migrations/template-var-assignment", + "//packages/core/schematics/migrations/undecorated-classes-with-di", "//packages/core/schematics/utils", "@npm//@angular-devkit/core", "@npm//@angular-devkit/schematics", diff --git a/packages/core/schematics/test/helpers.ts b/packages/core/schematics/test/helpers.ts new file mode 100644 index 0000000000000..ca6ea773df00e --- /dev/null +++ b/packages/core/schematics/test/helpers.ts @@ -0,0 +1,28 @@ +/** + * @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 + */ + +/** + * Template string function that can be used to dedent the resulting + * string literal. The smallest common indentation will be omitted. + */ +export function dedent(strings: TemplateStringsArray, ...values: any[]) { + let joinedString = ''; + for (let i = 0; i < values.length; i++) { + joinedString += `${strings[i]}${values[i]}`; + } + joinedString += strings[strings.length - 1]; + + const matches = joinedString.match(/^[ \t]*(?=\S)/gm); + if (matches === null) { + return joinedString; + } + + const minLineIndent = Math.min(...matches.map(el => el.length)); + const omitMinIndentRegex = new RegExp(`^[ \\t]{${minLineIndent}}`, 'gm'); + return minLineIndent > 0 ? joinedString.replace(omitMinIndentRegex, '') : joinedString; +} diff --git a/packages/core/schematics/test/undecorated_classes_with_di_migration_spec.ts b/packages/core/schematics/test/undecorated_classes_with_di_migration_spec.ts new file mode 100644 index 0000000000000..0d090c3868b0a --- /dev/null +++ b/packages/core/schematics/test/undecorated_classes_with_di_migration_spec.ts @@ -0,0 +1,1412 @@ +/** + * @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 {getSystemPath, normalize, virtualFs} from '@angular-devkit/core'; +import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing'; +import {HostTree} from '@angular-devkit/schematics'; +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; +import * as shx from 'shelljs'; +import {dedent} from './helpers'; + +describe('Undecorated classes with DI migration', () => { + let runner: SchematicTestRunner; + let host: TempScopedNodeJsSyncHost; + let tree: UnitTestTree; + let tmpDirPath: string; + let previousWorkingDir: string; + let warnOutput: string[]; + let errorOutput: string[]; + + beforeEach(() => { + runner = new SchematicTestRunner('test', require.resolve('../migrations.json')); + host = new TempScopedNodeJsSyncHost(); + tree = new UnitTestTree(new HostTree(host)); + + writeFakeAngular(); + writeFile('/tsconfig.json', JSON.stringify({ + compilerOptions: { + lib: ['es2015'], + } + })); + writeFile('/angular.json', JSON.stringify({ + projects: {t: {architect: {build: {options: {tsConfig: './tsconfig.json'}}}}} + })); + + warnOutput = []; + errorOutput = []; + runner.logger.subscribe(logEntry => { + if (logEntry.level === 'warn') { + warnOutput.push(logEntry.message); + } else if (logEntry.level === 'error') { + errorOutput.push(logEntry.message); + } + }); + + previousWorkingDir = shx.pwd(); + tmpDirPath = getSystemPath(host.root); + + // Switch into the temporary directory path. This allows us to run + // the schematic against our custom unit test tree. + shx.cd(tmpDirPath); + }); + + afterEach(() => { + shx.cd(previousWorkingDir); + shx.rm('-r', tmpDirPath); + }); + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { + runner.runSchematic('migration-v9-undecorated-classes-with-di', {}, tree); + } + + function writeFakeAngular() { + writeFile('/node_modules/@angular/core/index.d.ts', ` + export declare class PipeTransform {} + export declare class NgZone {} + export declare enum ViewEncapsulation { + None = 2 + } + `); + } + + it('should print a failure message base class is declared through type definition', () => { + writeFile('/node_modules/my-lib/package.json', JSON.stringify({ + version: '0.0.0', + main: './index.js', + typings: './index.d.ts', + })); + writeFile('/node_modules/my-lib/index.d.ts', ` + import {NgZone} from '@angular/core'; + + export declare class SuperBaseClass { + constructor(zone: NgZone); + } + `); + + writeFile('/index.ts', ` + import {Component, NgModule} from '@angular/core'; + import {SuperBaseClass} from 'my-lib'; + + export class BaseClass extends SuperBaseClass {} + + @Component({template: ''}) + export class MyComponent extends BaseClass {} + + @NgModule({declarations: [MyComponent]}) + export class MyModule {} + `); + + runMigration(); + + expect(errorOutput.length).toBe(0); + expect(warnOutput.length).toBe(1); + expect(warnOutput[0]).toMatch(/Class needs to declare an explicit constructor./); + }); + + it('should add @Directive() decorator to extended base class', () => { + writeFile('/index.ts', ` + import {Component, NgModule, NgZone} from '@angular/core'; + + export class BaseClass { + constructor(zone: NgZone) {} + } + + export class BaseClass2 { + constructor(zone: NgZone) {} + } + + @Component({template: ''}) + export class MyComponent extends BaseClass {} + + @Component({template: ''}) + export class MyComponent2 extends BaseClass2 {} + + @NgModule({declarations: [MyComponent, MyComponent2]}) + export class AppModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toMatch(/@Directive\(\)\nexport class BaseClass {/); + expect(tree.readContent('/index.ts')).toMatch(/@Directive\(\)\nexport class BaseClass2 {/); + }); + + it('not decorated base class multiple times if extended multiple times', () => { + writeFile('/index.ts', dedent ` + import {Component, NgModule, NgZone} from '@angular/core'; + + export class BaseClass { + constructor(zone: NgZone) {} + } + + @Component({template: ''}) + export class MyComponent extends BaseClass {} + + @Component({template: ''}) + export class MyComponent2 extends BaseClass {} + + @NgModule({declarations: [MyComponent, MyComponent2]}) + export class AppModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + + @Directive() + export class BaseClass { + constructor(zone: NgZone) {} + }`); + }); + + it('should add @Injectable() decorator to extended base class', () => { + writeFile('/index.ts', ` + import {Injectable, NgModule, NgZone} from '@angular/core'; + + export class BaseClass { + constructor(zone: NgZone) {} + } + + @Injectable({template: ''}) + export class MyService extends BaseClass {} + + @NgModule({providers: [MyService]}) + export class AppModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\nexport class BaseClass {/); + }); + + it('should not decorate base class for decorated pipe', () => { + writeFile('/index.ts', dedent ` + import {Component, NgModule, Pipe, PipeTransform} from '@angular/core'; + + @Pipe({name: 'test'}) + export class MyPipe extends PipeTransform {} + + @NgModule({declarations: [MyPipe]}) + export class AppModule {} + `); + + runMigration(); + + expect(errorOutput.length).toBe(0); + expect(warnOutput.length).toBe(0); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Pipe({name: 'test'}) + export class MyPipe extends PipeTransform {}`); + }); + + it('should not decorate base class if directive/component/provider defines a constructor', () => { + writeFile('/index.ts', dedent ` + import {Component, Injectable, NgModule, NgZone} from '@angular/core'; + + export class BaseClass { + constructor(zone: NgZone) {} + } + + export class BaseClass { + constructor(zone: NgZone) {} + } + + @Component({template: ''}) + export class MyComponent extends BaseClass { + constructor(zone: NgZone) { + super(zone); + } + } + + @Injectable() + export class MyService extends BaseClass { + constructor(zone: NgZone) { + super(zone); + } + } + + @NgModule({declarations: [MyComponent], providers: [MyService]}) + export class AppModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + + export class BaseClass { + constructor(zone: NgZone) {} + }`); + }); + + it('should not decorate base class if it already has decorator', () => { + writeFile('/index.ts', dedent ` + import {Component, Directive, NgModule, NgZone} from '@angular/core'; + + @Directive({selector: 'base-class'}) + export class BaseClass { + constructor(zone: NgZone) {} + } + + @Component({template: ''}) + export class MyComponent extends BaseClass {} + + @NgModule({declarations: [MyComponent]}) + export class AppModule {} + + @NgModule({declarations: [BaseClass]}) + export class LibModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + + @Directive({selector: 'base-class'}) + export class BaseClass {`); + }); + + it('should add a comment if the base class is declared through type definition', () => { + writeFile('/node_modules/my-lib/package.json', JSON.stringify({ + version: '0.0.0', + main: './index.js', + typings: './index.d.ts', + })); + writeFile('/node_modules/my-lib/index.d.ts', ` + import {NgZone} from '@angular/core'; + + export declare class SuperBaseClass { + constructor(zone: NgZone); + } + `); + + writeFile('/index.ts', dedent ` + import {Component, Injectable, NgModule} from '@angular/core'; + import {SuperBaseClass} from 'my-lib'; + + export class BaseClass extends SuperBaseClass {} + + export class BaseClass2 extends SuperBaseClass {} + + export class PassThroughClass extends BaseClass {} + + // should cause "BaseClass" to get a todo comment. + @Component({template: ''}) + export class MyComponent extends PassThroughClass {} + + // should cause "BaseClass2" to get a todo comment. + @Injectable() + export class MyService extends BaseClass2 {} + + // should cause "BaseClass" to get a todo comment. + @Component({template: ''}) + export class MyComponent2 extends BaseClass {} + + // should get a todo comment because there are no base classes + // in between. + @Component({template: ''}) + export class MyComponent3 extends SuperBaseClass {} + + @NgModule({declarations: [MyComponent, MyComponent2, MyComponent3], providers: [MyService]}) + export class MyModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Directive() + export class BaseClass extends SuperBaseClass { + // TODO: add explicit constructor + }`); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Injectable() + export class BaseClass2 extends SuperBaseClass { + // TODO: add explicit constructor + }`); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Directive() + export class PassThroughClass extends BaseClass {}`); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({template: ''}) + export class MyComponent extends PassThroughClass {}`); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({template: ''}) + export class MyComponent3 extends SuperBaseClass { + // TODO: add explicit constructor + }`); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Injectable() + export class MyService extends BaseClass2 {}`); + }); + + it('should not add a comment if the base class is declared through type definition but is' + + 'decorated', + () => { + writeFakeLibrary(); + writeFile('/index.ts', dedent ` + import {Component, NgModule} from '@angular/core'; + import {BaseComponent} from 'my-lib'; + + @Component({template: ''}) + export class MyComponent extends BaseComponent {} + + @NgModule({declarations: [MyComponent]}) + export class MyModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({template: ''}) + export class MyComponent extends BaseComponent {}`); + }); + + it('should not decorate base class in typings if it misses an explicit constructor', () => { + writeFakeLibrary(); + writeFile('/index.ts', dedent ` + import {Component, NgModule} from '@angular/core'; + import {BaseDirective} from 'my-lib'; + + @Component({template: ''}) + export class MyComponent extends BaseDirective {} + + @NgModule({declarations: [MyComponent]}) + export class MyModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({template: ''}) + export class MyComponent extends BaseDirective {}`); + expect(tree.readContent('/node_modules/my-lib/public-api.d.ts')).not.toContain('@Directive'); + }); + + it('should detect decorated classes by respecting summary files', () => { + writeSummaryOnlyThirdPartyLibrary(); + + writeFile('/index.ts', dedent ` + import {Component, NgModule} from '@angular/core'; + import {BaseComponent} from 'my-lib'; + + @Component({template: ''}) + export class MyComponent extends BaseComponent {} + + @NgModule({declarations: [MyComponent]}) + export class MyModule {} + `); + + runMigration(); + + expect(warnOutput.length).toBe(0); + expect(errorOutput.length).toBe(0); + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({template: ''}) + export class MyComponent extends BaseComponent {}`); + }); + + it('should decorate all undecorated directives of inheritance chain', () => { + writeFile('/index.ts', ` + import {Component, NgModule, NgZone} from '@angular/core'; + + export class SuperBaseClass { + constructor(zone: NgZone) {} + } + + export class BaseClass extends SuperBaseClass {} + + @Component({template: ''}) + export class MyComponent extends BaseClass {} + + @NgModule({declarations: [MyComponent]}) + export class MyModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toMatch(/@Directive\(\)\nexport class SuperBaseClass {/); + expect(tree.readContent('/index.ts')) + .toMatch(/}\s+@Directive\(\)\nexport class BaseClass extends SuperBaseClass {/); + }); + + it('should decorate all undecorated providers of inheritance chain', () => { + writeFile('/index.ts', ` + import {Injectable, NgModule, NgZone} from '@angular/core'; + + export class SuperBaseClass { + constructor(zone: NgZone) {} + } + + export class BaseClass extends SuperBaseClass {} + + @Injectable() + export class MyService extends BaseClass {} + + @NgModule({providers: [MyService]}) + export class MyModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\nexport class SuperBaseClass {/); + expect(tree.readContent('/index.ts')) + .toMatch(/}\s+@Injectable\(\)\nexport class BaseClass extends SuperBaseClass {/); + }); + + it('should properly update import if @Directive can be accessed through existing namespace import', + () => { + writeFile('/index.ts', ` + import {Component, NgModule, NgZone} from '@angular/core'; + import {BaseClass} from './base'; + + @Component({template: ''}) + export class A extends BaseClass {} + + @NgModule({declarations: [A]}) + export class MyModule {} + `); + + writeFile('/base.ts', ` + import * as core from '@angular/core'; + + export class BaseClass { + constructor(zone: core.NgZone) {} + } + `); + + runMigration(); + + expect(tree.readContent('/base.ts')).toMatch(/@core.Directive\(\)\nexport class BaseClass/); + }); + + it('should properly update existing import with aliased specifier if identifier is already used', + () => { + writeFile('/index.ts', ` + import {Component, NgModule, NgZone} from '@angular/core'; + import {Directive} from './third_party_directive'; + + export class BaseClass { + constructor(zone: NgZone) {} + } + + @Component({template: ''}) + export class MyComponent extends BaseClass {} + + @NgModule({declarations: [MyComponent]}) + export class AppModule {} + `); + + runMigration(); + + expect(tree.readContent(`/index.ts`)) + .toContain(`{ Component, NgModule, NgZone, Directive as Directive_1 }`); + expect(tree.readContent('/index.ts')).toMatch(/@Directive_1\(\)\nexport class BaseClass/); + }); + + it('should properly create new import with aliased specifier if identifier is already used', + () => { + writeFile('/index.ts', ` + import {Component, NgModule, NgZone} from '@angular/core'; + import {BaseClass} from './base'; + + @Component({template: ''}) + export class A extends BaseClass {} + + @NgModule({declarations: [A]}) + export class MyModule {} + `); + + writeFile('/base.ts', ` + import {Directive} from './external'; + + export class MyService {} + + export class BaseClass { + constructor(zone: MyService) {} + } + `); + + runMigration(); + + expect(tree.readContent('/base.ts')).toMatch(/@Directive_1\(\)\nexport class BaseClass/); + expect(tree.readContent(`/base.ts`)) + .toContain(`{ Directive as Directive_1 } from "@angular/core";`); + }); + + it('should use existing aliased import of @Directive instead of creating new import', () => { + writeFile('/index.ts', ` + import {Component, NgModule} from '@angular/core'; + import {BaseClass} from './base'; + + @Component({template: ''}) + export class A extends BaseClass {} + + @NgModule({declarations: [A]}) + export class MyModule {} + `); + + writeFile('/base.ts', ` + import {Directive as AliasedDir, NgZone} from '@angular/core'; + + export class BaseClass { + constructor(zone: NgZone) {} + } + `); + + runMigration(); + + expect(tree.readContent('/base.ts')).toMatch(/@AliasedDir\(\)\nexport class BaseClass {/); + }); + + describe('decorator copying', () => { + + it('should be able to copy the "templateUrl" field', () => { + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BaseClass} from './lib/base'; + + export class MyDir extends BaseClass {} + + @NgModule({declarations: [MyDir]}) + export class MyModule {} + `); + + writeFile('/lib/base.ts', dedent ` + import {Directive, NgModule} from '@angular/core'; + + @Directive({ + selector: 'my-dir', + templateUrl: './my-dir.html', + }) + export class BaseClass {} + + @NgModule({declarations: [BaseClass]}) + export class LibModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`import { NgModule, Directive } from '@angular/core';`); + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Directive({ + selector: 'my-dir', + templateUrl: './my-dir.html' + }) + export class MyDir extends BaseClass {}`); + }); + + it('should be able to copy the "styleUrls" field', () => { + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BaseClass} from './lib/base'; + + export class MyDir extends BaseClass {} + + @NgModule({declarations: [MyDir]}) + export class MyModule {} + `); + + writeFile('/lib/base.ts', dedent ` + import {Directive, NgModule} from '@angular/core'; + + /** my comment */ + @Directive({ + selector: 'my-dir', + styleUrls: ['./my-dir.css'], + }) + export class BaseClass {} + + @NgModule({declarations: [BaseClass]}) + export class LibModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + import {BaseClass} from './lib/base'; + + @Directive({ + selector: 'my-dir', + styleUrls: ['./my-dir.css'] + }) + export class MyDir extends BaseClass {}`); + }); + + it('should be able to copy @Pipe decorator', () => { + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BasePipe} from './lib/base'; + + export class MyPipe extends BasePipe {} + + @NgModule({declarations: [MyPipe]}) + export class MyModule {} + `); + + writeFile('/lib/base.ts', dedent ` + import {Pipe, NgModule} from '@angular/core'; + + @Pipe({name: 'my-pipe-name'}) + export class BasePipe {} + + @NgModule({declarations: [BasePipe]}) + export class LibModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`import { NgModule, Pipe } from '@angular/core';`); + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Pipe({ name: 'my-pipe-name' }) + export class MyPipe extends BasePipe {}`); + }); + + it('should be able to copy decorator in same source file', () => { + writeFile( + '/node_modules/@angular/cdk/table/index.d.ts', + `export declare const CDK_TABLE_TEMPLATE = '';`); + writeFile('/index.ts', dedent ` + import {NgModule, Component} from '@angular/core'; + import {CDK_TABLE_TEMPLATE} from '@angular/cdk/table'; + + const A = 'hello'; + + @Component({ + selector: 'my-dir', + template: CDK_TABLE_TEMPLATE, + styles: [A], + }) + export class BaseClass {} + + export class MyDir extends BaseClass {} + + @NgModule({declarations: [BaseClass]}) + export class LibModule {} + + @NgModule({declarations: [MyDir]}) + export class MyModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({ + selector: 'my-dir', + template: CDK_TABLE_TEMPLATE, + styles: [A], + }) + export class MyDir extends BaseClass {}`); + }); + + it('should be able to create new imports for copied identifier references', () => { + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BaseClass} from './lib/base'; + + export class MyDir extends BaseClass {} + + @NgModule({declarations: [[MyDir]]}) + export class MyModule {} + `); + + writeFile( + '/node_modules/@angular/cdk/table/index.d.ts', + `export declare const CDK_TABLE_TEMPLATE = '';`); + writeFile('/styles.ts', `export const STYLE_THROUGH_VAR = 'external';`); + writeFile('/lib/base.ts', dedent ` + import {Component, NgModule} from '@angular/core'; + import {CDK_TABLE_TEMPLATE as tableTmpl} from '@angular/cdk/table'; + import {STYLE_THROUGH_VAR} from '../styles'; + + export const LOCAL_STYLE = 'local_style'; + + @Component({ + selector: 'my-dir', + template: tableTmpl, + styles: [STYLE_THROUGH_VAR, LOCAL_STYLE] + }) + export class BaseClass {} + + @NgModule({declarations: [BaseClass]}) + export class LibModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`import { CDK_TABLE_TEMPLATE } from "@angular/cdk/table";`); + expect(tree.readContent('/index.ts')) + .toContain(`import { STYLE_THROUGH_VAR } from "./styles";`); + expect(tree.readContent('/index.ts')) + .toContain(`import { BaseClass, LOCAL_STYLE } from './lib/base';`); + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({ + selector: 'my-dir', + template: CDK_TABLE_TEMPLATE, + styles: [STYLE_THROUGH_VAR, LOCAL_STYLE] + }) + export class MyDir extends BaseClass {}`); + }); + + it('should copy decorator once if directive is referenced multiple times', () => { + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BaseClass} from './lib/base'; + + export class MyComp extends BaseClass {} + + @NgModule({entryComponents: [MyComp]}) + export class MyModule {} + `); + + writeFile('/second-module.ts', dedent ` + import {NgModule, Directive} from '@angular/core'; + import {MyComp} from './index'; + + @Directive({selector: 'other-dir'}) + export class OtherDir {} + + @NgModule({declarations: [OtherDir, [MyComp]], entryComponents: [MyComp]}) + export class MySecondModule {} + `); + + writeFile('/lib/base.ts', dedent ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + selector: 'my-dir', + template: '', + }) + export class BaseClass {} + + @NgModule({declarations: [BaseClass]}) + export class LibModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + import {BaseClass} from './lib/base'; + + @Component({ + selector: 'my-dir', + template: '' + }) + export class MyComp extends BaseClass {}`); + }); + + it('should create aliased imports to avoid collisions for referenced identifiers', () => { + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BaseClass} from './lib/base'; + + // this will conflict if "MY_TEMPLATE" from the base class is imported. The + // import to that export from base class should be aliased to avoid the collision. + const MY_TEMPLATE = ''; + + export class MyComp extends BaseClass {} + + @NgModule({declarations: [MyComp]}) + export class MyModule {} + `); + + writeFile('/lib/base.ts', dedent ` + import {Component, NgModule} from '@angular/core'; + + export const MY_TEMPLATE = ''; + + @Component({ + selector: 'my-dir', + template: MY_TEMPLATE, + }) + export class BaseClass {} + + @NgModule({declarations: [BaseClass]}) + export class LibModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`import { BaseClass, MY_TEMPLATE as MY_TEMPLATE_1 } from './lib/base';`); + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({ + selector: 'my-dir', + template: MY_TEMPLATE_1 + }) + export class MyComp extends BaseClass {}`); + }); + + it('should add comment for metadata fields which cannot be copied', () => { + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BaseClass} from './lib/base'; + + export class MyComp extends BaseClass {} + + @NgModule({declarations: [MyComp]}) + export class MyModule {} + `); + + writeFile('/lib/base.ts', dedent ` + import {Component, NgModule, Document} from '@angular/core'; + + // this variable cannot be imported automatically. + const someProviders = [{provide: Document, useValue: null}] + + @Component({ + selector: 'my-dir', + template: '', + providers: [...someProviders], + }) + export class BaseClass {} + + @NgModule({declarations: [BaseClass]}) + export class LibModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({ + selector: 'my-dir', + template: '', + // The following fields were copied from the base class, + // but could not be updated automatically to work in the + // new file location. Please add any required imports for + // the properties below: + providers: [...someProviders] + }) + export class MyComp extends BaseClass {}`); + }); + + it('should add comment for metadata fields which are added through spread operator', () => { + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BaseClass} from './lib/base'; + + export class MyComp extends BaseClass {} + + @NgModule({declarations: [[MyComp]]}) + export class MyModule {} + `); + + writeFile('/lib/base.ts', dedent ` + import {Component, NgModule} from '@angular/core'; + + export const metadataThroughVar = { + styleUrls: ['./test.css'], + } + + @Component({ + selector: 'my-dir', + template: '', + ...metadataThroughVar, + }) + export class BaseClass {} + + @NgModule({declarations: [BaseClass]}) + export class LibModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({ + selector: 'my-dir', + template: '', + // The following fields were copied from the base class, + // but could not be updated automatically to work in the + // new file location. Please add any required imports for + // the properties below: + ...metadataThroughVar + }) + export class MyComp extends BaseClass {}`); + }); + + it('should be able to copy fields specified through shorthand assignment', () => { + writeFile('/hello.css', ''); + writeFile('/my-tmpl.html', ''); + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BaseClass} from './lib/base'; + + export class MyComp extends BaseClass {} + + @NgModule({declarations: [MyComp]}) + export class MyModule {} + `); + + writeFile('/lib/hello.css', ''); + writeFile('/lib/my-tmpl.html', ''); + writeFile('/lib/base.ts', dedent ` + import {Component, NgModule} from '@angular/core'; + + export const host = {}; + export const templateUrl = './my-tmpl.html'; + const styleUrls = ["hello.css"]; + + @Component({ + selector: 'my-dir', + templateUrl, + styleUrls, + host, + }) + export class BaseClass {} + + @NgModule({declarations: [BaseClass]}) + export class LibModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`import { BaseClass, templateUrl, host } from './lib/base';`); + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({ + selector: 'my-dir', + templateUrl, + host, + // The following fields were copied from the base class, + // but could not be updated automatically to work in the + // new file location. Please add any required imports for + // the properties below: + styleUrls + }) + export class MyComp extends BaseClass {}`); + }); + + it('should serialize metadata from base class without source code', () => { + writeFakeLibrary(); + + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BaseComponent, BasePipe} from 'my-lib'; + + export class PassThrough extends BaseComponent {} + + @NgModule({declarations: [PassThrough]}) + export class MyPassThroughMod {} + + export class MyComp extends PassThrough {} + + export class MyPipe extends BasePipe {} + + @NgModule({declarations: [MyComp, MyPipe]}) + export class MyModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain( + `import { NgModule, ChangeDetectionStrategy, ViewEncapsulation, NG_VALIDATORS, Component, Pipe } from '@angular/core';`); + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({ + changeDetection: ChangeDetectionStrategy.Default, + selector: "comp-selector", + template: "My Lib Component", + encapsulation: ViewEncapsulation.None, + providers: [{ + provide: NG_VALIDATORS, + useExisting: BaseComponent, + multi: true + }] + }) + export class PassThrough extends BaseComponent {}`); + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({ + changeDetection: ChangeDetectionStrategy.Default, + selector: "comp-selector", + template: "My Lib Component", + encapsulation: ViewEncapsulation.None, + providers: [{ + provide: NG_VALIDATORS, + useExisting: BaseComponent, + multi: true + }] + }) + export class MyComp extends PassThrough {}`); + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Pipe({ + pure: true, + name: "external-pipe-name" + }) + export class MyPipe extends BasePipe {}`); + }); + + it('should serialize metadata with external references from class without source code', () => { + writeFakeLibrary({useImportedTemplate: true}); + writeFile( + '/node_modules/@angular/cdk/table/index.d.ts', + `export declare const CDK_TABLE_TEMPLATE = 'Template of CDK Table.';`); + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BaseComponent} from 'my-lib'; + + export class MyComp extends BaseComponent {} + + @NgModule({declarations: [MyComp]}) + export class MyModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain( + `import { NgModule, ChangeDetectionStrategy, ViewEncapsulation, NG_VALIDATORS, Component } from '@angular/core';`); + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Component({ + changeDetection: ChangeDetectionStrategy.Default, + selector: "comp-selector", + template: "Template of CDK Table.", + encapsulation: ViewEncapsulation.None, + providers: [{ + provide: NG_VALIDATORS, + useExisting: BaseComponent, + multi: true + }] + }) + export class MyComp extends BaseComponent {}`); + }); + + it('should not throw if metadata from base class without source code is not serializable', + () => { + writeFakeLibrary({insertInvalidReference: true}); + + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BaseComponent} from 'my-lib'; + + export class MyComp extends BaseComponent {} + + @NgModule({declarations: [MyComp]}) + export class MyModule {} + `); + + expect(() => runMigration()).not.toThrow(); + + expect(errorOutput.length).toBe(1); + expect(errorOutput[0]).toMatch(/Could not resolve non-existent/); + }); + + it('should not create imports for identifiers resolving to target source file', () => { + writeFile('/index.ts', dedent ` + import {NgModule} from '@angular/core'; + import {BaseClass} from './lib/base'; + + export const SHARED_TEMPLATE_URL = ''; + export const LOCAL_NAME = ''; + + export class MyDir extends BaseClass {} + + @NgModule({declarations: [MyDir]}) + export class MyModule {} + + export {LOCAL_NAME as PUBLIC_NAME}; + `); + + writeFile('/lib/base.ts', dedent ` + import {Directive, NgModule} from '@angular/core'; + import {SHARED_TEMPLATE_URL, PUBLIC_NAME} from '..'; + + @Directive({ + selector: 'my-dir', + template: SHARED_TEMPLATE_URL, + styleUrls: [PUBLIC_NAME] + }) + export class BaseClass {} + + @NgModule({declarations: [BaseClass]}) + export class LibModule {} + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`import { NgModule, Directive } from '@angular/core';`); + expect(tree.readContent('/index.ts')).toContain(dedent ` + @Directive({ + selector: 'my-dir', + template: SHARED_TEMPLATE_URL, + styleUrls: [ + LOCAL_NAME + ] + }) + export class MyDir extends BaseClass {}`); + }); + }); + + function writeFakeLibrary(options?: { + insertInvalidReference?: boolean, + useImportedTemplate?: boolean, + }) { + writeFile('/node_modules/my-lib/package.json', JSON.stringify({ + name: 'my-lib', + version: '0.0.0', + main: './index.js', + typings: './index.d.ts', + })); + writeFile('/node_modules/my-lib/index.d.ts', `export * from './public-api';`); + writeFile('/node_modules/my-lib/public-api.d.ts', ` + import {NgZone} from '@angular/core'; + + export const testValidators: any; + export declare class BasePipe {} + export declare class BaseDirective {} + export declare class BaseComponent { + constructor(zone: NgZone); + } + `); + writeFile('/node_modules/my-lib/index.metadata.json', JSON.stringify({ + __symbolic: 'module', + version: 4, + metadata: { + MyLibModule: { + __symbolic: 'class', + decorators: [{ + __symbolic: 'call', + expression: { + __symbolic: 'reference', + module: '@angular/core', + name: 'NgModule', + }, + arguments: [{ + declarations: [ + {__symbolic: 'reference', name: 'BaseComponent'}, + {__symbolic: 'reference', name: 'BasePipe'} + ] + }], + }], + }, + BasePipe: { + __symbolic: 'class', + decorators: [{ + __symbolic: 'call', + expression: {__symbolic: 'reference', module: '@angular/core', name: 'Pipe'}, + arguments: [{name: 'external-pipe-name'}], + }] + }, + testValidators: { + 'provide': + {'__symbolic': 'reference', 'module': '@angular/core', 'name': 'NG_VALIDATORS'}, + 'useExisting': {'__symbolic': 'reference', 'name': 'BaseComponent'}, + 'multi': true + }, + BaseComponent: { + __symbolic: 'class', + decorators: [{ + __symbolic: 'call', + expression: { + __symbolic: 'reference', + module: '@angular/core', + name: 'Component', + }, + arguments: [{ + selector: 'comp-selector', + template: options && options.useImportedTemplate ? { + __symbolic: 'reference', + module: '@angular/cdk/table', + name: 'CDK_TABLE_TEMPLATE', + } : + `My Lib Component`, + encapsulation: { + __symbolic: 'select', + expression: { + __symbolic: 'reference', + module: options && options.insertInvalidReference ? 'non-existent' : + '@angular/core', + name: options && options.insertInvalidReference ? 'NonExistent' : + 'ViewEncapsulation', + }, + member: 'None' + }, + providers: [{__symbolic: 'reference', name: 'testValidators'}], + }] + }], + members: {} + }, + }, + origins: { + BaseComponent: './public-api', + }, + importAs: 'my-lib', + })); + } + + function writeSummaryOnlyThirdPartyLibrary() { + writeFile('/tsconfig.json', JSON.stringify({ + compilerOptions: { + lib: ['es2015'], + }, + angularCompilerOptions: { + generateCodeForLibraries: false, + allowEmptyCodegenFiles: true, + enableSummariesForJit: true, + } + })); + + writeFile('/node_modules/my-lib/package.json', JSON.stringify({ + name: 'my-lib', + version: '0.0.0', + main: './index.js', + typings: './index.d.ts', + })); + writeFile('/node_modules/my-lib/index.d.ts', `export * from './public-api';`); + writeFile('/node_modules/my-lib/public-api.d.ts', ` + import {NgZone} from '@angular/core'; + + export declare class BaseComponent { + constructor(zone: NgZone); + } + `); + + writeFile('/node_modules/my-lib/index.ngsummary.json', JSON.stringify({ + 'moduleName': null, + 'summaries': [ + {'symbol': {'__symbol': 0, 'members': []}, 'metadata': {'__symbol': 1, 'members': []}}, + ], + 'symbols': [ + {'__symbol': 0, 'name': 'BaseComponent', 'filePath': './index'}, + {'__symbol': 1, 'name': 'BaseComponent', 'filePath': './public-api'}, + ] + })); + + writeFile('/node_modules/my-lib/public-api.ngsummary.json', JSON.stringify({ + 'moduleName': null, + 'summaries': [{ + 'symbol': {'__symbol': 0, 'members': []}, + 'metadata': {'__symbolic': 'class', 'members': {}}, + 'type': { + 'summaryKind': 1, + 'type': { + 'reference': {'__symbol': 0, 'members': []}, + 'diDeps': [{ + 'isAttribute': false, + 'isHost': false, + 'isSelf': false, + 'isSkipSelf': false, + 'isOptional': false, + 'token': {'identifier': {'reference': {'__symbol': 4, 'members': []}}} + }], + 'lifecycleHooks': [] + }, + 'isComponent': false, + 'selector': 'button[cdkStepperNext]', + 'exportAs': null, + 'inputs': {'type': 'type'}, + 'outputs': {}, + 'hostListeners': {'click': '_handleClick()'}, + 'hostProperties': {'type': 'type'}, + 'hostAttributes': {}, + 'providers': [], + 'viewProviders': [], + 'queries': [], + 'guards': {}, + 'viewQueries': [], + 'entryComponents': [], + 'changeDetection': null, + 'template': null, + 'componentViewType': null, + 'rendererType': null, + 'componentFactory': null + } + }], + 'symbols': [{'__symbol': 0, 'name': 'BaseComponent', 'filePath': './public-api'}] + })); + } + + it('should not run for test tsconfig files', () => { + writeFile('/src/tsconfig.spec.json', JSON.stringify({ + compilerOptions: { + lib: ['es2015'], + }, + files: ['./index.spec.ts'] + })); + + writeFile('/src/index.spec.ts', ` + // This imports "AppComponent" but *not* the actual module. Therefore + // the module is not part of the TypeScript project and NGC would error + // since the component is not part of any NgModule. This is way we can't + // create the Angular compiler program for test tsconfig files. + import {AppComponent} from './app.component'; + `); + + writeFile('/src/app.component.ts', ` + import {Component} from '@angular/core'; + + @Component({template: ''}) + export class AppComponent {} + `); + + writeFile('/src/app.module.ts', ` + import {NgModule} from '@angular/core'; + import {AppComponent} from './app.component'; + + @NgModule({declarations: [AppComponent]}) + export class AppModule {} + `); + + runMigration(); + + // If the test project would run as part of the migration, there would be + // error messages because test projects are not guaranteed to always contain + // all source files. In this test it misses the "AppModule" which means that + // NGC would fail because the app component is not part of any module. + expect(warnOutput.length).toBe(0); + expect(errorOutput.length).toBe(0); + }); + + describe('diagnostics', () => { + it('should gracefully exit migration if project fails with structural diagnostic', () => { + writeFile('/index.ts', ` + import {Component, NgModule} from '@angular/core'; + + @Component({template: ''}) + export class TestComp {} + + @NgModule({declarations: [/* TestComp not added */]}) + export class MyModule {} + `); + + runMigration(); + + expect(warnOutput.length).toBe(1); + expect(warnOutput[0]) + .toMatch( + /ensure there are no AOT compilation errors and rerun the migration.*project failed: tsconfig\.json/); + expect(errorOutput.length).toBe(1); + expect(errorOutput[0]).toMatch(/Cannot determine the module for class TestComp/); + }); + + it('should gracefully exit migration if project fails with syntactical diagnostic', () => { + writeFile('/index.ts', ` + import {Component, NgModule} /* missing "from" */ '@angular/core'; + `); + + runMigration(); + + expect(warnOutput.length).toBe(1); + expect(warnOutput[0]) + .toMatch(/project "tsconfig.json" has syntactical errors which could cause/); + expect(errorOutput.length).toBe(1); + expect(errorOutput[0]).toMatch(/error TS1005: 'from' expected/); + }); + }); +}); diff --git a/packages/core/schematics/tsconfig.json b/packages/core/schematics/tsconfig.json index 21b63c7f32a3a..39011671983e4 100644 --- a/packages/core/schematics/tsconfig.json +++ b/packages/core/schematics/tsconfig.json @@ -7,6 +7,7 @@ "types": [], "baseUrl": ".", "paths": { + "@angular/core": ["../"], "@angular/compiler": ["../../compiler"], "@angular/compiler/*": ["../../compiler/*"], "@angular/compiler-cli": ["../../compiler-cli"], diff --git a/packages/core/schematics/utils/ng_decorators.ts b/packages/core/schematics/utils/ng_decorators.ts index f0b688d1e7612..4e2fadea1c6ff 100644 --- a/packages/core/schematics/utils/ng_decorators.ts +++ b/packages/core/schematics/utils/ng_decorators.ts @@ -15,6 +15,7 @@ export type CallExpressionDecorator = ts.Decorator & { export interface NgDecorator { name: string; + moduleName: string; node: CallExpressionDecorator; importNode: ts.ImportDeclaration; } @@ -30,6 +31,7 @@ export function getAngularDecorators( .map(({node, importData}) => ({ node: node as CallExpressionDecorator, name: importData !.name, + moduleName: importData !.importModule, importNode: importData !.node })); } diff --git a/packages/core/schematics/utils/typescript/class_declaration.ts b/packages/core/schematics/utils/typescript/class_declaration.ts index 3a835539b62de..0150fa39ce698 100644 --- a/packages/core/schematics/utils/typescript/class_declaration.ts +++ b/packages/core/schematics/utils/typescript/class_declaration.ts @@ -30,3 +30,8 @@ export function findParentClassDeclaration(node: ts.Node): ts.ClassDeclaration|n } return node; } + +/** Checks whether the given class declaration has an explicit constructor or not. */ +export function hasExplicitConstructor(node: ts.ClassDeclaration): boolean { + return node.members.some(ts.isConstructorDeclaration); +} diff --git a/packages/core/schematics/utils/typescript/symbol.ts b/packages/core/schematics/utils/typescript/symbol.ts new file mode 100644 index 0000000000000..e76c9a8a2f95e --- /dev/null +++ b/packages/core/schematics/utils/typescript/symbol.ts @@ -0,0 +1,20 @@ +/** + * @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'; + +export function getValueSymbolOfDeclaration(node: ts.Node, typeChecker: ts.TypeChecker): ts.Symbol| + undefined { + let symbol = typeChecker.getSymbolAtLocation(node); + + while (symbol && symbol.flags & ts.SymbolFlags.Alias) { + symbol = typeChecker.getAliasedSymbol(symbol); + } + + return symbol; +}