diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts b/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts new file mode 100644 index 0000000000..a971f85a8b --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts @@ -0,0 +1,133 @@ +/** + * @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 {ExternalExpr, Identifiers, InvokeFunctionExpr, Statement, WrappedNodeExpr} from '@angular/compiler'; +import * as ts from 'typescript'; + +import {CtorParameter, Decorator, ReflectionHost} from '../../host'; + +/** + * Given a class declaration, generate a call to `setClassMetadata` with the Angular metadata + * present on the class or its member fields. + * + * If no such metadata is present, this function returns `null`. Otherwise, the call is returned + * as a `Statement` for inclusion along with the class. + */ +export function generateSetClassMetadataCall( + clazz: ts.Declaration, reflection: ReflectionHost, isCore: boolean): Statement|null { + // Classes come in two flavors, class declarations (ES2015) and variable declarations (ES5). + // Both must have a declared name to have metadata set on them. + if ((!ts.isClassDeclaration(clazz) && !ts.isVariableDeclaration(clazz)) || + clazz.name === undefined || !ts.isIdentifier(clazz.name)) { + return null; + } + const id = ts.updateIdentifier(clazz.name); + + // Reflect over the class decorators. If none are present, or those that are aren't from + // Angular, then return null. Otherwise, turn them into metadata. + const classDecorators = reflection.getDecoratorsOfDeclaration(clazz); + if (classDecorators === null) { + return null; + } + const ngClassDecorators = + classDecorators.filter(dec => isAngularDecorator(dec, isCore)).map(decoratorToMetadata); + if (ngClassDecorators.length === 0) { + return null; + } + const metaDecorators = ts.createArrayLiteral(ngClassDecorators); + + // Convert the constructor parameters to metadata, passing null if none are present. + let metaCtorParameters: ts.Expression = ts.createNull(); + const classCtorParameters = reflection.getConstructorParameters(clazz); + if (classCtorParameters !== null) { + metaCtorParameters = ts.createArrayLiteral( + classCtorParameters.map(param => ctorParameterToMetadata(param, isCore))); + } + + // Do the same for property decorators. + let metaPropDecorators: ts.Expression = ts.createNull(); + const decoratedMembers = + reflection.getMembersOfClass(clazz) + .filter(member => !member.isStatic && member.decorators !== null) + .map(member => classMemberToMetadata(member.name, member.decorators !, isCore)); + if (decoratedMembers.length > 0) { + metaPropDecorators = ts.createObjectLiteral(decoratedMembers); + } + + // Generate a pure call to setClassMetadata with the class identifier and its metadata. + const setClassMetadata = new ExternalExpr(Identifiers.setClassMetadata); + const fnCall = new InvokeFunctionExpr( + /* fn */ setClassMetadata, + /* args */ + [ + new WrappedNodeExpr(id), + new WrappedNodeExpr(metaDecorators), + new WrappedNodeExpr(metaCtorParameters), + new WrappedNodeExpr(metaPropDecorators), + ], + /* type */ undefined, + /* sourceSpan */ undefined, + /* pure */ true); + return fnCall.toStmt(); +} + +/** + * Convert a reflected constructor parameter to metadata. + */ +function ctorParameterToMetadata(param: CtorParameter, isCore: boolean): ts.Expression { + // Parameters sometimes have a type that can be referenced. If so, then use it, otherwise + // its type is undefined. + const type = param.type !== null ? param.type : ts.createIdentifier('undefined'); + const properties: ts.ObjectLiteralElementLike[] = [ + ts.createPropertyAssignment('type', type), + ]; + + // If the parameter has decorators, include the ones from Angular. + if (param.decorators !== null) { + const ngDecorators = + param.decorators.filter(dec => isAngularDecorator(dec, isCore)).map(decoratorToMetadata); + properties.push(ts.createPropertyAssignment('decorators', ts.createArrayLiteral(ngDecorators))); + } + return ts.createObjectLiteral(properties, true); +} + +/** + * Convert a reflected class member to metadata. + */ +function classMemberToMetadata( + name: string, decorators: Decorator[], isCore: boolean): ts.PropertyAssignment { + const ngDecorators = + decorators.filter(dec => isAngularDecorator(dec, isCore)).map(decoratorToMetadata); + const decoratorMeta = ts.createArrayLiteral(ngDecorators); + return ts.createPropertyAssignment(name, decoratorMeta); +} + +/** + * Convert a reflected decorator to metadata. + */ +function decoratorToMetadata(decorator: Decorator): ts.ObjectLiteralExpression { + // Decorators have a type. + const properties: ts.ObjectLiteralElementLike[] = [ + ts.createPropertyAssignment('type', ts.updateIdentifier(decorator.identifier)), + ]; + // Sometimes they have arguments. + if (decorator.args !== null && decorator.args.length > 0) { + const args = decorator.args.map(arg => ts.getMutableClone(arg)); + properties.push(ts.createPropertyAssignment('args', ts.createArrayLiteral(args))); + } + return ts.createObjectLiteral(properties, true); +} + +/** + * Whether a given decorator should be treated as an Angular decorator. + * + * Either it's used in @angular/core, or it's imported from there. + */ +function isAngularDecorator(decorator: Decorator, isCore: boolean): boolean { + return isCore || (decorator.import !== null && decorator.import.from === '@angular/core'); +} diff --git a/packages/compiler-cli/src/ngtsc/annotations/test/BUILD.bazel b/packages/compiler-cli/src/ngtsc/annotations/test/BUILD.bazel index 4c7441a80e..fc283563c0 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/test/BUILD.bazel +++ b/packages/compiler-cli/src/ngtsc/annotations/test/BUILD.bazel @@ -15,6 +15,7 @@ ts_library( "//packages/compiler-cli/src/ngtsc/diagnostics", "//packages/compiler-cli/src/ngtsc/metadata", "//packages/compiler-cli/src/ngtsc/testing", + "//packages/compiler-cli/src/ngtsc/translator", "@ngdeps//typescript", ], ) diff --git a/packages/compiler-cli/src/ngtsc/annotations/test/metadata_spec.ts b/packages/compiler-cli/src/ngtsc/annotations/test/metadata_spec.ts new file mode 100644 index 0000000000..67ae1ac3a2 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/annotations/test/metadata_spec.ts @@ -0,0 +1,94 @@ +/** + * @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 {Statement} from '@angular/compiler'; +import * as ts from 'typescript'; + +import {TypeScriptReflectionHost} from '../../metadata'; +import {getDeclaration, makeProgram} from '../../testing/in_memory_typescript'; +import {ImportManager, translateStatement} from '../../translator'; +import {generateSetClassMetadataCall} from '../src/metadata'; + +const CORE = { + name: 'node_modules/@angular/core/index.d.ts', + contents: ` + export declare function Input(...args: any[]): any; + export declare function Inject(...args: any[]): any; + export declare function Component(...args: any[]): any; + export declare class Injector {} + ` +}; + +describe('ngtsc setClassMetadata converter', () => { + it('should convert decorated class metadata', () => { + const res = compileAndPrint(` + import {Component} from '@angular/core'; + + @Component('metadata') class Target {} + `); + expect(res).toEqual( + `/*@__PURE__*/ i0.ɵsetClassMetadata(Target, [{ type: Component, args: ['metadata'] }], null, null);`); + }); + + it('should convert decorated class construtor parameter metadata', () => { + const res = compileAndPrint(` + import {Component, Inject, Injector} from '@angular/core'; + const FOO = 'foo'; + + @Component('metadata') class Target { + constructor(@Inject(FOO) foo: any, bar: Injector) {} + } + `); + expect(res).toContain( + `[{ type: undefined, decorators: [{ type: Inject, args: [FOO] }] }, { type: Injector }], null);`); + }); + + it('should convert decorated field metadata', () => { + const res = compileAndPrint(` + import {Component, Input} from '@angular/core'; + + @Component('metadata') class Target { + @Input() foo: string; + + @Input('value') bar: string; + + notDecorated: string; + } + `); + expect(res).toContain(`{ foo: [{ type: Input }], bar: [{ type: Input, args: ['value'] }] })`); + }); + + it('should not convert non-angular decorators to metadata', () => { + const res = compileAndPrint(` + declare function NotAComponent(...args: any[]): any; + + @NotAComponent('metadata') class Target {} + `); + expect(res).toBe(''); + }); +}); + +function compileAndPrint(contents: string): string { + const {program} = makeProgram([ + CORE, { + name: 'index.ts', + contents, + } + ]); + const host = new TypeScriptReflectionHost(program.getTypeChecker()); + const target = getDeclaration(program, 'index.ts', 'Target', ts.isClassDeclaration); + const call = generateSetClassMetadataCall(target, host, false); + if (call === null) { + return ''; + } + const sf = program.getSourceFile('index.ts') !; + const im = new ImportManager(false, 'i'); + const tsStatement = translateStatement(call, im); + const res = ts.createPrinter().printNode(ts.EmitHint.Unspecified, tsStatement, sf); + return res.replace(/\s+/g, ' '); +}