diff --git a/karma-js.conf.js b/karma-js.conf.js index aab3ad13ca..815b36fba9 100644 --- a/karma-js.conf.js +++ b/karma-js.conf.js @@ -65,6 +65,8 @@ module.exports = function(config) { 'dist/all/@angular/**/*node_only_spec.js', 'dist/all/@angular/benchpress/**', 'dist/all/@angular/compiler-cli/**', + 'dist/all/@angular/compiler-cli/src/ngtsc/**', + 'dist/all/@angular/compiler-cli/test/ngtsc/**', 'dist/all/@angular/compiler/test/aot/**', 'dist/all/@angular/compiler/test/render3/**', 'dist/all/@angular/core/test/bundling/**', diff --git a/packages/compiler-cli/BUILD.bazel b/packages/compiler-cli/BUILD.bazel index 8638eef706..e75f0399a2 100644 --- a/packages/compiler-cli/BUILD.bazel +++ b/packages/compiler-cli/BUILD.bazel @@ -25,6 +25,7 @@ ts_library( tsconfig = ":tsconfig", deps = [ "//packages/compiler", + "//packages/compiler-cli/src/ngtsc/transform", ], ) diff --git a/packages/compiler-cli/src/main.ts b/packages/compiler-cli/src/main.ts index 9fc109d752..25dcd54281 100644 --- a/packages/compiler-cli/src/main.ts +++ b/packages/compiler-cli/src/main.ts @@ -40,7 +40,8 @@ export function main( function createEmitCallback(options: api.CompilerOptions): api.TsEmitCallback|undefined { - const transformDecorators = options.annotationsAs !== 'decorators'; + const transformDecorators = + options.enableIvy !== 'ngtsc' && options.annotationsAs !== 'decorators'; const transformTypesToClosure = options.annotateForClosureCompiler; if (!transformDecorators && !transformTypesToClosure) { return undefined; diff --git a/packages/compiler-cli/src/ngtsc/compiler_host.ts b/packages/compiler-cli/src/ngtsc/compiler_host.ts new file mode 100644 index 0000000000..d8fef66138 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/compiler_host.ts @@ -0,0 +1,77 @@ +/** + * @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'; + +/** + * The TypeScript compiler host used by `ngtsc`. + * + * It's mostly identical to the native `CompilerHost`, but also includes the ability to + * asynchronously resolve resources. + */ +export interface CompilerHost extends ts.CompilerHost { + /** + * Begin processing a resource file. + * + * When the returned Promise resolves, `loadResource` should be able to synchronously produce a + * `string` for the given file. + */ + preloadResource(file: string): Promise; + + /** + * Like `readFile`, but reads the contents of a resource file which may have been pre-processed + * by `preloadResource`. + */ + loadResource(file: string): string|undefined; +} + +/** + * Implementation of `CompilerHost` which delegates to a native TypeScript host in most cases. + */ +export class NgtscCompilerHost implements CompilerHost { + constructor(private delegate: ts.CompilerHost) {} + + getSourceFile( + fileName: string, languageVersion: ts.ScriptTarget, + onError?: ((message: string) => void)|undefined, + shouldCreateNewSourceFile?: boolean|undefined): ts.SourceFile|undefined { + return this.delegate.getSourceFile( + fileName, languageVersion, onError, shouldCreateNewSourceFile); + } + + getDefaultLibFileName(options: ts.CompilerOptions): string { + return this.delegate.getDefaultLibFileName(options); + } + + writeFile( + fileName: string, data: string, writeByteOrderMark: boolean, + onError: ((message: string) => void)|undefined, + sourceFiles: ReadonlyArray): void { + return this.delegate.writeFile(fileName, data, writeByteOrderMark, onError, sourceFiles); + } + + getCurrentDirectory(): string { return this.delegate.getCurrentDirectory(); } + + getDirectories(path: string): string[] { return this.delegate.getDirectories(path); } + + getCanonicalFileName(fileName: string): string { + return this.delegate.getCanonicalFileName(fileName); + } + + useCaseSensitiveFileNames(): boolean { return this.delegate.useCaseSensitiveFileNames(); } + + getNewLine(): string { return this.delegate.getNewLine(); } + + fileExists(fileName: string): boolean { return this.delegate.fileExists(fileName); } + + readFile(fileName: string): string|undefined { return this.delegate.readFile(fileName); } + + loadResource(file: string): string|undefined { throw new Error('Method not implemented.'); } + + preloadResource(file: string): Promise { throw new Error('Method not implemented.'); } +} diff --git a/packages/compiler-cli/src/ngtsc/metadata/BUILD.bazel b/packages/compiler-cli/src/ngtsc/metadata/BUILD.bazel new file mode 100644 index 0000000000..48bc2b903a --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/metadata/BUILD.bazel @@ -0,0 +1,15 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "metadata", + srcs = glob([ + "index.ts", + "src/*.ts", + ]), + module_name = "@angular/compiler-cli/src/ngtsc/metadata", + deps = [ + "//packages/compiler", + ], +) diff --git a/packages/compiler-cli/src/ngtsc/metadata/index.ts b/packages/compiler-cli/src/ngtsc/metadata/index.ts new file mode 100644 index 0000000000..ecd28cd0f9 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/metadata/index.ts @@ -0,0 +1,10 @@ +/** + * @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 + */ + +export {Decorator, Parameter, reflectConstructorParameters, reflectDecorator} from './src/reflector'; +export {Reference, ResolvedValue, isDynamicValue, staticallyResolve} from './src/resolver'; diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts b/packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts new file mode 100644 index 0000000000..fd189d1915 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts @@ -0,0 +1,238 @@ +/** + * @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'; + +/** + * reflector.ts implements static reflection of declarations using the TypeScript `ts.TypeChecker`. + */ + +/** + * A reflected parameter of a function, method, or constructor, indicating the name, any + * decorators, and an expression representing a reference to the value side of the parameter's + * declared type, if applicable. + */ +export interface Parameter { + /** + * Name of the parameter as a `ts.BindingName`, which allows the parameter name to be identified + * via sourcemaps. + */ + name: ts.BindingName; + + /** + * A `ts.Expression` which represents a reference to the value side of the parameter's type. + */ + typeValueExpr: ts.Expression|null; + + /** + * Array of decorators present on the parameter. + */ + decorators: Decorator[]; +} + +/** + * A reflected decorator, indicating the name, where it was imported from, and any arguments if the + * decorator is a call expression. + */ +export interface Decorator { + /** + * Name of the decorator, extracted from the decoration expression. + */ + name: string; + + /** + * Import path (relative to the decorator's file) of the decorator itself. + */ + from: string; + + /** + * The decorator node itself (useful for printing sourcemap based references to the decorator). + */ + node: ts.Decorator; + + /** + * Any arguments of a call expression, if one is present. If the decorator was not a call + * expression, then this will be an empty array. + */ + args: ts.Expression[]; +} + +/** + * Reflect a `ts.ClassDeclaration` and determine the list of parameters. + * + * Note that this only reflects the referenced class and not any potential parent class - that must + * be handled by the caller. + * + * @param node the `ts.ClassDeclaration` to reflect + * @param checker a `ts.TypeChecker` used for reflection + * @returns a `Parameter` instance for each argument of the constructor, or `null` if no constructor + */ +export function reflectConstructorParameters( + node: ts.ClassDeclaration, checker: ts.TypeChecker): Parameter[]|null { + // Firstly, look for a constructor. + // clang-format off + const maybeCtor: ts.ConstructorDeclaration[] = node + .members + .filter(element => ts.isConstructorDeclaration(element)) as ts.ConstructorDeclaration[]; + // clang-format on + + if (maybeCtor.length !== 1) { + // No constructor. + return null; + } + + // Reflect each parameter. + return maybeCtor[0].parameters.map(param => reflectParameter(param, checker)); +} + +/** + * Reflect a `ts.ParameterDeclaration` and determine its name, a token which refers to the value + * declaration of its type (if possible to statically determine), and its decorators, if any. + */ +function reflectParameter(node: ts.ParameterDeclaration, checker: ts.TypeChecker): Parameter { + // The name of the parameter is easy. + const name = node.name; + + const decorators = node.decorators && + node.decorators.map(decorator => reflectDecorator(decorator, checker)) + .filter(decorator => decorator !== null) as Decorator[] || + []; + + // It may or may not be possible to write an expression that refers to the value side of the + // type named for the parameter. + let typeValueExpr: ts.Expression|null = null; + + // It's not possible to get a value expression if the parameter doesn't even have a type. + if (node.type !== undefined) { + // It's only valid to convert a type reference to a value reference if the type actually has a + // value declaration associated with it. + const type = checker.getTypeFromTypeNode(node.type); + if (type.symbol !== undefined && type.symbol.valueDeclaration !== undefined) { + // The type points to a valid value declaration. Rewrite the TypeReference into an Expression + // which references the value pointed to by the TypeReference, if possible. + typeValueExpr = typeNodeToValueExpr(node.type); + } + } + + return { + name, typeValueExpr, decorators, + }; +} + +/** + * Reflect a decorator and return a structure describing where it comes from and any arguments. + * + * Only imported decorators are considered, not locally defined decorators. + */ +export function reflectDecorator(decorator: ts.Decorator, checker: ts.TypeChecker): Decorator|null { + // Attempt to resolve the decorator expression into a reference to a concrete Identifier. The + // expression may contain a call to a function which returns the decorator function, in which + // case we want to return the arguments. + let decoratorOfInterest: ts.Expression = decorator.expression; + let args: ts.Expression[] = []; + + // Check for call expressions. + if (ts.isCallExpression(decoratorOfInterest)) { + args = Array.from(decoratorOfInterest.arguments); + decoratorOfInterest = decoratorOfInterest.expression; + } + + // The final resolved decorator should be a `ts.Identifier` - if it's not, then something is + // wrong and the decorator can't be resolved statically. + if (!ts.isIdentifier(decoratorOfInterest)) { + return null; + } + + const importDecl = reflectImportedIdentifier(decoratorOfInterest, checker); + if (importDecl === null) { + return null; + } + + return { + ...importDecl, + node: decorator, args, + }; +} + +function typeNodeToValueExpr(node: ts.TypeNode): ts.Expression|null { + if (ts.isTypeReferenceNode(node)) { + return entityNameToValue(node.typeName); + } else { + return null; + } +} + +function entityNameToValue(node: ts.EntityName): ts.Expression|null { + if (ts.isQualifiedName(node)) { + const left = entityNameToValue(node.left); + return left !== null ? ts.createPropertyAccess(left, node.right) : null; + } else if (ts.isIdentifier(node)) { + return ts.updateIdentifier(node); + } else { + return null; + } +} + +function propertyNameToValue(node: ts.PropertyName): string|null { + if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) { + return node.text; + } else { + return null; + } +} + +export function reflectObjectLiteral(node: ts.ObjectLiteralExpression): Map { + const map = new Map(); + node.properties.forEach(prop => { + if (ts.isPropertyAssignment(prop)) { + const name = propertyNameToValue(prop.name); + if (name === null) { + return; + } + map.set(name, prop.initializer); + } else if (ts.isShorthandPropertyAssignment(prop)) { + map.set(prop.name.text, prop.name); + } else { + return; + } + }); + return map; +} + +export function reflectImportedIdentifier( + id: ts.Identifier, checker: ts.TypeChecker): {name: string, from: string}|null { + const symbol = checker.getSymbolAtLocation(id); + + if (symbol === undefined || symbol.declarations === undefined || + symbol.declarations.length !== 1) { + return null; + } + + // Ignore decorators that are defined locally (not imported). + const decl: ts.Declaration = symbol.declarations[0]; + if (!ts.isImportSpecifier(decl)) { + return null; + } + + // Walk back from the specifier to find the declaration, which carries the module specifier. + const importDecl = decl.parent !.parent !.parent !; + + // The module specifier is guaranteed to be a string literal, so this should always pass. + if (!ts.isStringLiteral(importDecl.moduleSpecifier)) { + // Not allowed to happen in TypeScript ASTs. + return null; + } + + // Read the module specifier. + const from = importDecl.moduleSpecifier.text; + + // Compute the name by which the decorator was exported, not imported. + const name = (decl.propertyName !== undefined ? decl.propertyName : decl.name).text; + + return {from, name}; +} diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts b/packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts new file mode 100644 index 0000000000..9f4e4ab200 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts @@ -0,0 +1,514 @@ +/** + * @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 + */ + +/** + * resolver.ts implements partial computation of expressions, resolving expressions to static + * values where possible and returning a `DynamicValue` signal when not. + */ + +import * as ts from 'typescript'; + +/** + * Represents a value which cannot be determined statically. + * + * Use `isDynamicValue` to determine whether a `ResolvedValue` is a `DynamicValue`. + */ +export class DynamicValue { + /** + * This is needed so the "is DynamicValue" assertion of `isDynamicValue` actually has meaning. + * + * Otherwise, "is DynamicValue" is akin to "is {}" which doesn't trigger narrowing. + */ + private _isDynamic = true; +} + +/** + * An internal flyweight for `DynamicValue`. Eventually the dynamic value will carry information + * on the location of the node that could not be statically computed. + */ +const DYNAMIC_VALUE: DynamicValue = new DynamicValue(); + +/** + * Used to test whether a `ResolvedValue` is a `DynamicValue`. + */ +export function isDynamicValue(value: any): value is DynamicValue { + return value === DYNAMIC_VALUE; +} + +/** + * A value resulting from static resolution. + * + * This could be a primitive, collection type, reference to a `ts.Node` that declares a + * non-primitive value, or a special `DynamicValue` type which indicates the value was not + * available statically. + */ +export type ResolvedValue = number | boolean | string | null | undefined | Reference | + ResolvedValueArray | ResolvedValueMap | DynamicValue; + +/** + * An array of `ResolvedValue`s. + * + * This is a reified type to allow the circular reference of `ResolvedValue` -> `ResolvedValueArray` + * -> + * `ResolvedValue`. + */ +export interface ResolvedValueArray extends Array {} + +/** + * A map of strings to `ResolvedValue`s. + * + * This is a reified type to allow the circular reference of `ResolvedValue` -> `ResolvedValueMap` -> + * `ResolvedValue`. + */ export interface ResolvedValueMap extends Map {} + +/** + * Tracks the scope of a function body, which includes `ResolvedValue`s for the parameters of that + * body. + */ +type Scope = Map; + +/** + * Whether or not to allow references during resolution. + * + * See `StaticInterpreter` for details. + */ +const enum AllowReferences { + No = 0, + Yes = 1, +} + +/** + * A reference to a `ts.Node`. + * + * For example, if an expression evaluates to a function or class definition, it will be returned + * as a `Reference` (assuming references are allowed in evaluation). + */ +export class Reference { + constructor(readonly node: ts.Node) {} +} + +/** + * Statically resolve the given `ts.Expression` into a `ResolvedValue`. + * + * @param node the expression to statically resolve if possible + * @param checker a `ts.TypeChecker` used to understand the expression + * @returns a `ResolvedValue` representing the resolved value + */ +export function staticallyResolve(node: ts.Expression, checker: ts.TypeChecker): ResolvedValue { + return new StaticInterpreter( + checker, new Map(), AllowReferences.No) + .visit(node); +} + +interface BinaryOperatorDef { + literal: boolean; + op: (a: any, b: any) => ResolvedValue; +} + +function literalBinaryOp(op: (a: any, b: any) => any): BinaryOperatorDef { + return {op, literal: true}; +} + +function referenceBinaryOp(op: (a: any, b: any) => any): BinaryOperatorDef { + return {op, literal: false}; +} + +const BINARY_OPERATORS = new Map([ + [ts.SyntaxKind.PlusToken, literalBinaryOp((a, b) => a + b)], + [ts.SyntaxKind.MinusToken, literalBinaryOp((a, b) => a - b)], + [ts.SyntaxKind.AsteriskToken, literalBinaryOp((a, b) => a * b)], + [ts.SyntaxKind.SlashToken, literalBinaryOp((a, b) => a / b)], + [ts.SyntaxKind.PercentToken, literalBinaryOp((a, b) => a % b)], + [ts.SyntaxKind.AmpersandToken, literalBinaryOp((a, b) => a & b)], + [ts.SyntaxKind.BarToken, literalBinaryOp((a, b) => a | b)], + [ts.SyntaxKind.CaretToken, literalBinaryOp((a, b) => a ^ b)], + [ts.SyntaxKind.LessThanToken, literalBinaryOp((a, b) => a < b)], + [ts.SyntaxKind.LessThanEqualsToken, literalBinaryOp((a, b) => a <= b)], + [ts.SyntaxKind.GreaterThanToken, literalBinaryOp((a, b) => a > b)], + [ts.SyntaxKind.GreaterThanEqualsToken, literalBinaryOp((a, b) => a >= b)], + [ts.SyntaxKind.LessThanLessThanToken, literalBinaryOp((a, b) => a << b)], + [ts.SyntaxKind.GreaterThanGreaterThanToken, literalBinaryOp((a, b) => a >> b)], + [ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken, literalBinaryOp((a, b) => a >>> b)], + [ts.SyntaxKind.AsteriskAsteriskToken, literalBinaryOp((a, b) => Math.pow(a, b))], + [ts.SyntaxKind.AmpersandAmpersandToken, referenceBinaryOp((a, b) => a && b)], + [ts.SyntaxKind.BarBarToken, referenceBinaryOp((a, b) => a || b)] +]); + +const UNARY_OPERATORS = new Map any>([ + [ts.SyntaxKind.TildeToken, a => ~a], [ts.SyntaxKind.MinusToken, a => -a], + [ts.SyntaxKind.PlusToken, a => +a], [ts.SyntaxKind.ExclamationToken, a => !a] +]); + +class StaticInterpreter { + constructor( + private checker: ts.TypeChecker, private scope: Scope, + private allowReferences: AllowReferences) {} + + visit(node: ts.Expression): ResolvedValue { return this.visitExpression(node); } + + private visitExpression(node: ts.Expression): ResolvedValue { + if (node.kind === ts.SyntaxKind.TrueKeyword) { + return true; + } else if (node.kind === ts.SyntaxKind.FalseKeyword) { + return false; + } else if (ts.isStringLiteral(node)) { + return node.text; + } else if (ts.isNumericLiteral(node)) { + return parseFloat(node.text); + } else if (ts.isObjectLiteralExpression(node)) { + return this.visitObjectLiteralExpression(node); + } else if (ts.isIdentifier(node)) { + return this.visitIdentifier(node); + } else if (ts.isPropertyAccessExpression(node)) { + return this.visitPropertyAccessExpression(node); + } else if (ts.isCallExpression(node)) { + return this.visitCallExpression(node); + } else if (ts.isConditionalExpression(node)) { + return this.visitConditionalExpression(node); + } else if (ts.isPrefixUnaryExpression(node)) { + return this.visitPrefixUnaryExpression(node); + } else if (ts.isBinaryExpression(node)) { + return this.visitBinaryExpression(node); + } else if (ts.isArrayLiteralExpression(node)) { + return this.visitArrayLiteralExpression(node); + } else if (ts.isParenthesizedExpression(node)) { + return this.visitParenthesizedExpression(node); + } else if (ts.isElementAccessExpression(node)) { + return this.visitElementAccessExpression(node); + } else if (ts.isAsExpression(node)) { + return this.visitExpression(node.expression); + } else if (ts.isNonNullExpression(node)) { + return this.visitExpression(node.expression); + } else if (ts.isClassDeclaration(node)) { + return this.visitDeclaration(node); + } else { + return DYNAMIC_VALUE; + } + } + + private visitArrayLiteralExpression(node: ts.ArrayLiteralExpression): ResolvedValue { + const array: ResolvedValueArray = []; + for (let i = 0; i < node.elements.length; i++) { + const element = node.elements[i]; + if (ts.isSpreadElement(element)) { + const spread = this.visitExpression(element.expression); + if (isDynamicValue(spread)) { + return DYNAMIC_VALUE; + } + if (!Array.isArray(spread)) { + throw new Error(`Unexpected value in spread expression: ${spread}`); + } + + array.push(...spread); + } else { + const result = this.visitExpression(element); + if (isDynamicValue(result)) { + return DYNAMIC_VALUE; + } + + array.push(result); + } + } + return array; + } + + private visitObjectLiteralExpression(node: ts.ObjectLiteralExpression): ResolvedValue { + const map: ResolvedValueMap = new Map(); + for (let i = 0; i < node.properties.length; i++) { + const property = node.properties[i]; + if (ts.isPropertyAssignment(property)) { + const name = this.stringNameFromPropertyName(property.name); + + // Check whether the name can be determined statically. + if (name === undefined) { + return DYNAMIC_VALUE; + } + + map.set(name, this.visitExpression(property.initializer)); + } else if (ts.isShorthandPropertyAssignment(property)) { + const symbol = this.checker.getShorthandAssignmentValueSymbol(property); + if (symbol === undefined || symbol.valueDeclaration === undefined) { + return DYNAMIC_VALUE; + } + map.set(property.name.text, this.visitDeclaration(symbol.valueDeclaration)); + } else if (ts.isSpreadAssignment(property)) { + const spread = this.visitExpression(property.expression); + if (isDynamicValue(spread)) { + return DYNAMIC_VALUE; + } + if (!(spread instanceof Map)) { + throw new Error(`Unexpected value in spread assignment: ${spread}`); + } + spread.forEach((value, key) => map.set(key, value)); + } else { + return DYNAMIC_VALUE; + } + } + return map; + } + + private visitIdentifier(node: ts.Identifier): ResolvedValue { + let symbol: ts.Symbol|undefined = this.checker.getSymbolAtLocation(node); + if (symbol === undefined) { + return DYNAMIC_VALUE; + } + const result = this.visitSymbol(symbol); + if (this.allowReferences === AllowReferences.Yes && isDynamicValue(result)) { + return new Reference(node); + } + return result; + } + + private visitSymbol(symbol: ts.Symbol): ResolvedValue { + while (symbol.flags & ts.SymbolFlags.Alias) { + symbol = this.checker.getAliasedSymbol(symbol); + } + + if (symbol.declarations === undefined) { + return DYNAMIC_VALUE; + } + + if (symbol.valueDeclaration !== undefined) { + return this.visitDeclaration(symbol.valueDeclaration); + } + + return symbol.declarations.reduce((prev, decl) => { + if (!(isDynamicValue(prev) || prev instanceof Reference)) { + return prev; + } + return this.visitDeclaration(decl); + }, DYNAMIC_VALUE); + } + + private visitDeclaration(node: ts.Declaration): ResolvedValue { + if (ts.isVariableDeclaration(node)) { + if (!node.initializer) { + return undefined; + } + return this.visitExpression(node.initializer); + } else if (ts.isParameter(node) && this.scope.has(node)) { + return this.scope.get(node) !; + } else if (ts.isExportAssignment(node)) { + return this.visitExpression(node.expression); + } else if (ts.isSourceFile(node)) { + return this.visitSourceFile(node); + } + return this.allowReferences === AllowReferences.Yes ? new Reference(node) : DYNAMIC_VALUE; + } + + private visitElementAccessExpression(node: ts.ElementAccessExpression): ResolvedValue { + const lhs = this.withReferences.visitExpression(node.expression); + if (node.argumentExpression === undefined) { + throw new Error(`Expected argument in ElementAccessExpression`); + } + if (isDynamicValue(lhs)) { + return DYNAMIC_VALUE; + } + const rhs = this.withNoReferences.visitExpression(node.argumentExpression); + if (isDynamicValue(rhs)) { + return DYNAMIC_VALUE; + } + if (typeof rhs !== 'string' && typeof rhs !== 'number') { + throw new Error( + `ElementAccessExpression index should be string or number, got ${typeof rhs}: ${rhs}`); + } + + return this.accessHelper(lhs, rhs); + } + + private visitPropertyAccessExpression(node: ts.PropertyAccessExpression): ResolvedValue { + const lhs = this.withReferences.visitExpression(node.expression); + const rhs = node.name.text; + // TODO: handle reference to class declaration. + if (isDynamicValue(lhs)) { + return DYNAMIC_VALUE; + } + + return this.accessHelper(lhs, rhs); + } + + private visitSourceFile(node: ts.SourceFile): ResolvedValue { + const map = new Map(); + const symbol = this.checker.getSymbolAtLocation(node); + if (symbol === undefined) { + return DYNAMIC_VALUE; + } + const exports = this.checker.getExportsOfModule(symbol); + exports.forEach(symbol => map.set(symbol.name, this.visitSymbol(symbol))); + + return map; + } + + private accessHelper(lhs: ResolvedValue, rhs: string|number): ResolvedValue { + const strIndex = `${rhs}`; + if (lhs instanceof Map) { + if (lhs.has(strIndex)) { + return lhs.get(strIndex) !; + } else { + throw new Error(`Invalid map access: [${Array.from(lhs.keys())}] dot ${rhs}`); + } + } else if (Array.isArray(lhs)) { + if (rhs === 'length') { + return rhs.length; + } + if (typeof rhs !== 'number' || !Number.isInteger(rhs)) { + return DYNAMIC_VALUE; + } + if (rhs < 0 || rhs >= lhs.length) { + throw new Error(`Index out of bounds: ${rhs} vs ${lhs.length}`); + } + return lhs[rhs]; + } else if (lhs instanceof Reference) { + const ref = lhs.node; + if (ts.isClassDeclaration(ref)) { + let value: ResolvedValue = undefined; + const member = ref.members.filter(member => isStatic(member)) + .find( + member => member.name !== undefined && + this.stringNameFromPropertyName(member.name) === strIndex); + if (member !== undefined) { + if (ts.isPropertyDeclaration(member) && member.initializer !== undefined) { + value = this.visitExpression(member.initializer); + } else if (ts.isMethodDeclaration(member)) { + value = this.allowReferences === AllowReferences.Yes ? new Reference(member) : + DYNAMIC_VALUE; + } + } + return value; + } + } + throw new Error(`Invalid dot property access: ${lhs} dot ${rhs}`); + } + + private visitCallExpression(node: ts.CallExpression): ResolvedValue { + const lhs = this.withReferences.visitExpression(node.expression); + if (!(lhs instanceof Reference)) { + throw new Error(`attempting to call something that is not a function: ${lhs}`); + } else if (!isFunctionOrMethodDeclaration(lhs.node) || !lhs.node.body) { + throw new Error( + `calling something that is not a function declaration? ${ts.SyntaxKind[lhs.node.kind]}`); + } + + const fn = lhs.node; + const body = fn.body as ts.Block; + if (body.statements.length !== 1 || !ts.isReturnStatement(body.statements[0])) { + throw new Error('Function body must have a single return statement only.'); + } + const ret = body.statements[0] as ts.ReturnStatement; + + const newScope: Scope = new Map(); + fn.parameters.forEach((param, index) => { + let value: ResolvedValue = undefined; + if (index < node.arguments.length) { + const arg = node.arguments[index]; + value = this.visitExpression(arg); + } + if (value === undefined && param.initializer !== undefined) { + value = this.visitExpression(param.initializer); + } + newScope.set(param, value); + }); + + return ret.expression !== undefined ? this.withScope(newScope).visitExpression(ret.expression) : + undefined; + } + + private visitConditionalExpression(node: ts.ConditionalExpression): ResolvedValue { + const condition = this.withNoReferences.visitExpression(node.condition); + if (isDynamicValue(condition)) { + return condition; + } + + if (condition) { + return this.visitExpression(node.whenTrue); + } else { + return this.visitExpression(node.whenFalse); + } + } + + private visitPrefixUnaryExpression(node: ts.PrefixUnaryExpression): ResolvedValue { + const operatorKind = node.operator; + if (!UNARY_OPERATORS.has(operatorKind)) { + throw new Error(`Unsupported prefix unary operator: ${ts.SyntaxKind[operatorKind]}`); + } + + const op = UNARY_OPERATORS.get(operatorKind) !; + const value = this.visitExpression(node.operand); + return isDynamicValue(value) ? DYNAMIC_VALUE : op(value); + } + + private visitBinaryExpression(node: ts.BinaryExpression): ResolvedValue { + const tokenKind = node.operatorToken.kind; + if (!BINARY_OPERATORS.has(tokenKind)) { + throw new Error(`Unsupported binary operator: ${ts.SyntaxKind[tokenKind]}`); + } + + const opRecord = BINARY_OPERATORS.get(tokenKind) !; + let lhs: ResolvedValue, rhs: ResolvedValue; + if (opRecord.literal) { + const withNoReferences = this.withNoReferences; + lhs = literal(withNoReferences.visitExpression(node.left)); + rhs = literal(withNoReferences.visitExpression(node.right)); + } else { + lhs = this.visitExpression(node.left); + rhs = this.visitExpression(node.right); + } + + return isDynamicValue(lhs) || isDynamicValue(rhs) ? DYNAMIC_VALUE : opRecord.op(lhs, rhs); + } + + private visitParenthesizedExpression(node: ts.ParenthesizedExpression): ResolvedValue { + return this.visitExpression(node.expression); + } + + private stringNameFromPropertyName(node: ts.PropertyName): string|undefined { + if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) { + return node.text; + } else { // ts.ComputedPropertyName + const literal = this.withNoReferences.visitExpression(node.expression); + return typeof literal === 'string' ? literal : undefined; + } + } + + private get withReferences(): StaticInterpreter { + return this.allowReferences === AllowReferences.Yes ? + this : + new StaticInterpreter(this.checker, this.scope, AllowReferences.Yes); + } + + private get withNoReferences(): StaticInterpreter { + return this.allowReferences === AllowReferences.No ? + this : + new StaticInterpreter(this.checker, this.scope, AllowReferences.No); + } + + private withScope(scope: Scope): StaticInterpreter { + return new StaticInterpreter(this.checker, scope, this.allowReferences); + } +} + +function isStatic(element: ts.ClassElement): boolean { + return element.modifiers !== undefined && + element.modifiers.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword); +} + +function isFunctionOrMethodDeclaration(node: ts.Node): node is ts.FunctionDeclaration| + ts.MethodDeclaration { + return ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node); +} + +function literal(value: ResolvedValue): any { + if (value === null || value === undefined || typeof value === 'string' || + typeof value === 'number' || typeof value === 'boolean') { + return value; + } + if (isDynamicValue(value)) { + return DYNAMIC_VALUE; + } + throw new Error(`Value ${value} is not literal and cannot be used in this context.`); +} diff --git a/packages/compiler-cli/src/ngtsc/metadata/test/BUILD.bazel b/packages/compiler-cli/src/ngtsc/metadata/test/BUILD.bazel new file mode 100644 index 0000000000..f62ae96cae --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/metadata/test/BUILD.bazel @@ -0,0 +1,25 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ts_library") +load("@build_bazel_rules_nodejs//:defs.bzl", "jasmine_node_test") + +ts_library( + name = "test_lib", + testonly = 1, + srcs = glob([ + "**/*.ts", + ]), + deps = [ + "//packages:types", + "//packages/compiler-cli/src/ngtsc/metadata", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = ["angular/tools/testing/init_node_no_angular_spec.js"], + deps = [ + ":test_lib", + "//tools/testing:node_no_angular", + ], +) diff --git a/packages/compiler-cli/src/ngtsc/metadata/test/in_memory_typescript.ts b/packages/compiler-cli/src/ngtsc/metadata/test/in_memory_typescript.ts new file mode 100644 index 0000000000..78f3206db7 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/metadata/test/in_memory_typescript.ts @@ -0,0 +1,129 @@ +/** + * @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 path from 'path'; +import * as ts from 'typescript'; + +export function makeProgram(files: {name: string, contents: string}[]): ts.Program { + const host = new InMemoryHost(); + files.forEach(file => host.writeFile(file.name, file.contents)); + + const rootNames = files.map(file => host.getCanonicalFileName(file.name)); + const program = ts.createProgram(rootNames, {noLib: true, experimentalDecorators: true}, host); + const diags = [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()]; + if (diags.length > 0) { + fail(diags.map(diag => diag.messageText).join(', ')); + throw new Error(`Typescript diagnostics failed!`); + } + return program; +} + +export class InMemoryHost implements ts.CompilerHost { + private fileSystem = new Map(); + + getSourceFile( + fileName: string, languageVersion: ts.ScriptTarget, + onError?: ((message: string) => void)|undefined, + shouldCreateNewSourceFile?: boolean|undefined): ts.SourceFile|undefined { + const contents = this.fileSystem.get(this.getCanonicalFileName(fileName)); + if (contents === undefined) { + onError && onError(`File does not exist: ${this.getCanonicalFileName(fileName)})`); + return undefined; + } + return ts.createSourceFile(fileName, contents, languageVersion, undefined, ts.ScriptKind.TS); + } + + getDefaultLibFileName(options: ts.CompilerOptions): string { return '/lib.d.ts'; } + + writeFile( + fileName: string, data: string, writeByteOrderMark?: boolean, + onError?: ((message: string) => void)|undefined, + sourceFiles?: ReadonlyArray): void { + this.fileSystem.set(this.getCanonicalFileName(fileName), data); + } + + getCurrentDirectory(): string { return '/'; } + + getDirectories(dir: string): string[] { + const fullDir = this.getCanonicalFileName(dir) + '/'; + const dirSet = new Set(Array + // Look at all paths known to the host. + .from(this.fileSystem.keys()) + // Filter out those that aren't under the requested directory. + .filter(candidate => candidate.startsWith(fullDir)) + // Relativize the rest by the requested directory. + .map(candidate => candidate.substr(fullDir.length)) + // What's left are dir/.../file.txt entries, and file.txt entries. + // Get the dirname, which + // yields '.' for the latter and dir/... for the former. + .map(candidate => path.dirname(candidate)) + // Filter out the '.' entries, which were files. + .filter(candidate => candidate !== '.') + // Finally, split on / and grab the first entry. + .map(candidate => candidate.split('/', 1)[0])); + + // Get the resulting values out of the Set. + return Array.from(dirSet); + } + + getCanonicalFileName(fileName: string): string { + return path.posix.normalize(`${this.getCurrentDirectory()}/${fileName}`); + } + + useCaseSensitiveFileNames(): boolean { return true; } + + getNewLine(): string { return '\n'; } + + fileExists(fileName: string): boolean { return this.fileSystem.has(fileName); } + + readFile(fileName: string): string|undefined { return this.fileSystem.get(fileName); } +} + +function bindingNameEquals(node: ts.BindingName, name: string): boolean { + if (ts.isIdentifier(node)) { + return node.text === name; + } + return false; +} + +export function getDeclaration( + program: ts.Program, fileName: string, name: string, assert: (value: any) => value is T): T { + const sf = program.getSourceFile(fileName); + if (!sf) { + throw new Error(`No such file: ${fileName}`); + } + + let chosenDecl: ts.Declaration|null = null; + + sf.statements.forEach(stmt => { + if (chosenDecl !== null) { + return; + } else if (ts.isVariableStatement(stmt)) { + stmt.declarationList.declarations.forEach(decl => { + if (bindingNameEquals(decl.name, name)) { + chosenDecl = decl; + } + }); + } else if (ts.isClassDeclaration(stmt) || ts.isFunctionDeclaration(stmt)) { + if (stmt.name !== undefined && stmt.name.text === name) { + chosenDecl = stmt; + } + } + }); + + chosenDecl = chosenDecl as ts.Declaration | null; + + if (chosenDecl === null) { + throw new Error(`No such symbol: ${name} in ${fileName}`); + } + if (!assert(chosenDecl)) { + throw new Error(`Symbol ${name} from ${fileName} is a ${ts.SyntaxKind[chosenDecl.kind]}`); + } + + return chosenDecl; +} diff --git a/packages/compiler-cli/src/ngtsc/metadata/test/reflector_spec.ts b/packages/compiler-cli/src/ngtsc/metadata/test/reflector_spec.ts new file mode 100644 index 0000000000..0d66504794 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/metadata/test/reflector_spec.ts @@ -0,0 +1,145 @@ +/** + * @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 {Parameter, reflectConstructorParameters} from '../src/reflector'; + +import {getDeclaration, makeProgram} from './in_memory_typescript'; + +describe('reflector', () => { + describe('ctor params', () => { + it('should reflect a single argument', () => { + const program = makeProgram([{ + name: 'entry.ts', + contents: ` + class Bar {} + + class Foo { + constructor(bar: Bar) {} + } + ` + }]); + const clazz = getDeclaration(program, 'entry.ts', 'Foo', ts.isClassDeclaration); + const checker = program.getTypeChecker(); + const args = reflectConstructorParameters(clazz, checker) !; + expect(args.length).toBe(1); + expectArgument(args[0], 'bar', 'Bar'); + }); + + it('should reflect a decorated argument', () => { + const program = makeProgram([ + { + name: 'dec.ts', + contents: ` + export function dec(target: any, key: string, index: number) { + } + ` + }, + { + name: 'entry.ts', + contents: ` + import {dec} from './dec'; + class Bar {} + + class Foo { + constructor(@dec bar: Bar) {} + } + ` + } + ]); + const clazz = getDeclaration(program, 'entry.ts', 'Foo', ts.isClassDeclaration); + const checker = program.getTypeChecker(); + const args = reflectConstructorParameters(clazz, checker) !; + expect(args.length).toBe(1); + expectArgument(args[0], 'bar', 'Bar', 'dec', './dec'); + }); + + it('should reflect a decorated argument with a call', () => { + const program = makeProgram([ + { + name: 'dec.ts', + contents: ` + export function dec(target: any, key: string, index: number) { + } + ` + }, + { + name: 'entry.ts', + contents: ` + import {dec} from './dec'; + class Bar {} + + class Foo { + constructor(@dec bar: Bar) {} + } + ` + } + ]); + const clazz = getDeclaration(program, 'entry.ts', 'Foo', ts.isClassDeclaration); + const checker = program.getTypeChecker(); + const args = reflectConstructorParameters(clazz, checker) !; + expect(args.length).toBe(1); + expectArgument(args[0], 'bar', 'Bar', 'dec', './dec'); + }); + + it('should reflect a decorated argument with an indirection', () => { + const program = makeProgram([ + { + name: 'bar.ts', + contents: ` + export class Bar {} + ` + }, + { + name: 'entry.ts', + contents: ` + import {Bar} from './bar'; + import * as star from './bar'; + + class Foo { + constructor(bar: Bar, otherBar: star.Bar) {} + } + ` + } + ]); + const clazz = getDeclaration(program, 'entry.ts', 'Foo', ts.isClassDeclaration); + const checker = program.getTypeChecker(); + const args = reflectConstructorParameters(clazz, checker) !; + expect(args.length).toBe(2); + expectArgument(args[0], 'bar', 'Bar'); + expectArgument(args[1], 'otherBar', 'star.Bar'); + }); + }); +}); + +function expectArgument( + arg: Parameter, name: string, type?: string, decorator?: string, decoratorFrom?: string): void { + expect(argExpressionToString(arg.name)).toEqual(name); + if (type === undefined) { + expect(arg.typeValueExpr).toBeNull(); + } else { + expect(arg.typeValueExpr).not.toBeNull(); + expect(argExpressionToString(arg.typeValueExpr !)).toEqual(type); + } + if (decorator !== undefined) { + expect(arg.decorators.length).toBeGreaterThan(0); + expect(arg.decorators.some(dec => dec.name === decorator && dec.from === decoratorFrom)) + .toBe(true); + } +} + +function argExpressionToString(name: ts.Node): string { + if (ts.isIdentifier(name)) { + return name.text; + } else if (ts.isPropertyAccessExpression(name)) { + return `${argExpressionToString(name.expression)}.${name.name.text}`; + } else { + throw new Error(`Unexpected node in arg expression: ${ts.SyntaxKind[name.kind]}.`); + } +} \ No newline at end of file diff --git a/packages/compiler-cli/src/ngtsc/metadata/test/resolver_spec.ts b/packages/compiler-cli/src/ngtsc/metadata/test/resolver_spec.ts new file mode 100644 index 0000000000..04920b8efb --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/metadata/test/resolver_spec.ts @@ -0,0 +1,193 @@ +/** + * @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 {ResolvedValue, staticallyResolve} from '../src/resolver'; + +import {getDeclaration, makeProgram} from './in_memory_typescript'; + +function makeSimpleProgram(contents: string): ts.Program { + return makeProgram([{name: 'entry.ts', contents}]); +} + +function makeExpression( + code: string, expr: string): {expression: ts.Expression, checker: ts.TypeChecker} { + const program = makeProgram([{name: 'entry.ts', contents: `${code}; const target$ = ${expr};`}]); + const checker = program.getTypeChecker(); + const decl = getDeclaration(program, 'entry.ts', 'target$', ts.isVariableDeclaration); + return { + expression: decl.initializer !, + checker, + }; +} + +function evaluate(code: string, expr: string): T { + const {expression, checker} = makeExpression(code, expr); + return staticallyResolve(expression, checker) as T; +} + +describe('ngtsc metadata', () => { + it('reads a file correctly', () => { + const program = makeProgram([ + { + name: 'entry.ts', + contents: ` + import {Y} from './other'; + const A = Y; + export const X = A; + ` + }, + { + name: 'other.ts', + contents: ` + export const Y = 'test'; + ` + } + ]); + const decl = getDeclaration(program, 'entry.ts', 'X', ts.isVariableDeclaration); + + const value = staticallyResolve(decl.initializer !, program.getTypeChecker()); + expect(value).toEqual('test'); + }); + + it('map access works', + () => { expect(evaluate('const obj = {a: "test"};', 'obj.a')).toEqual('test'); }); + + it('function calls work', () => { + expect(evaluate(`function foo(bar) { return bar; }`, 'foo("test")')).toEqual('test'); + }); + + it('conditionals work', () => { + expect(evaluate(`const x = false; const y = x ? 'true' : 'false';`, 'y')).toEqual('false'); + }); + + it('addition works', () => { expect(evaluate(`const x = 1 + 2;`, 'x')).toEqual(3); }); + + it('static property on class works', + () => { expect(evaluate(`class Foo { static bar = 'test'; }`, 'Foo.bar')).toEqual('test'); }); + + it('static property call works', () => { + expect(evaluate(`class Foo { static bar(test) { return test; } }`, 'Foo.bar("test")')) + .toEqual('test'); + }); + + it('indirected static property call works', () => { + expect( + evaluate( + `class Foo { static bar(test) { return test; } }; const fn = Foo.bar;`, 'fn("test")')) + .toEqual('test'); + }); + + it('array works', () => { + expect(evaluate(`const x = 'test'; const y = [1, x, 2];`, 'y')).toEqual([1, 'test', 2]); + }); + + it('array spread works', () => { + expect(evaluate(`const a = [1, 2]; const b = [4, 5]; const c = [...a, 3, ...b];`, 'c')) + .toEqual([1, 2, 3, 4, 5]); + }); + + it('&& operations work', () => { + expect(evaluate(`const a = 'hello', b = 'world';`, 'a && b')).toEqual('world'); + expect(evaluate(`const a = false, b = 'world';`, 'a && b')).toEqual(false); + expect(evaluate(`const a = 'hello', b = 0;`, 'a && b')).toEqual(0); + }); + + it('|| operations work', () => { + expect(evaluate(`const a = 'hello', b = 'world';`, 'a || b')).toEqual('hello'); + expect(evaluate(`const a = false, b = 'world';`, 'a || b')).toEqual('world'); + expect(evaluate(`const a = 'hello', b = 0;`, 'a || b')).toEqual('hello'); + }); + + it('parentheticals work', + () => { expect(evaluate(`const a = 3, b = 4;`, 'a * (a + b)')).toEqual(21); }); + + it('array access works', + () => { expect(evaluate(`const a = [1, 2, 3];`, 'a[1] + a[0]')).toEqual(3); }); + + it('negation works', () => { + expect(evaluate(`const x = 3;`, '!x')).toEqual(false); + expect(evaluate(`const x = 3;`, '!!x')).toEqual(true); + }); + + it('reads values from default exports', () => { + const program = makeProgram([ + {name: 'second.ts', contents: 'export default {property: "test"}'}, + { + name: 'entry.ts', + contents: ` + import mod from './second'; + const target$ = mod.property; + ` + }, + ]); + const checker = program.getTypeChecker(); + const result = getDeclaration(program, 'entry.ts', 'target$', ts.isVariableDeclaration); + const expr = result.initializer !; + debugger; + expect(staticallyResolve(expr, checker)).toEqual('test'); + }); + + it('reads values from named exports', () => { + const program = makeProgram([ + {name: 'second.ts', contents: 'export const a = {property: "test"};'}, + { + name: 'entry.ts', + contents: ` + import * as mod from './second'; + const target$ = mod.a.property; + ` + }, + ]); + const checker = program.getTypeChecker(); + const result = getDeclaration(program, 'entry.ts', 'target$', ts.isVariableDeclaration); + const expr = result.initializer !; + expect(staticallyResolve(expr, checker)).toEqual('test'); + }); + + it('chain of re-exports works', () => { + const program = makeProgram([ + {name: 'const.ts', contents: 'export const value = {property: "test"};'}, + {name: 'def.ts', contents: `import {value} from './const'; export default value;`}, + {name: 'indirect-reexport.ts', contents: `import value from './def'; export {value};`}, + {name: 'direct-reexport.ts', contents: `export {value} from './indirect-reexport';`}, + { + name: 'entry.ts', + contents: `import * as mod from './direct-reexport'; const target$ = mod.value.property;` + }, + ]); + const checker = program.getTypeChecker(); + const result = getDeclaration(program, 'entry.ts', 'target$', ts.isVariableDeclaration); + const expr = result.initializer !; + expect(staticallyResolve(expr, checker)).toEqual('test'); + }); + + it('map spread works', () => { + const map: Map = evaluate>( + `const a = {a: 1}; const b = {b: 2, c: 1}; const c = {...a, ...b, c: 3};`, 'c'); + + const obj: {[key: string]: number} = {}; + map.forEach((value, key) => obj[key] = value); + expect(obj).toEqual({ + a: 1, + b: 2, + c: 3, + }); + }); + + it('indirected-via-object function call works', () => { + expect(evaluate( + ` + function fn(res) { return res; } + const obj = {fn}; + `, + 'obj.fn("test")')) + .toEqual('test'); + }); +}); diff --git a/packages/compiler-cli/src/ngtsc/program.ts b/packages/compiler-cli/src/ngtsc/program.ts new file mode 100644 index 0000000000..579a415f02 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/program.ts @@ -0,0 +1,145 @@ +/** + * @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 {GeneratedFile} from '@angular/compiler'; +import * as path from 'path'; +import * as ts from 'typescript'; + +import * as api from '../transformers/api'; + +import {CompilerHost} from './compiler_host'; +import {InjectableCompilerAdapter, IvyCompilation, ivyTransformFactory} from './transform'; + +export class NgtscProgram implements api.Program { + private tsProgram: ts.Program; + + constructor( + rootNames: ReadonlyArray, private options: api.CompilerOptions, + private host: api.CompilerHost, oldProgram?: api.Program) { + this.tsProgram = + ts.createProgram(rootNames, options, host, oldProgram && oldProgram.getTsProgram()); + } + + getTsProgram(): ts.Program { return this.tsProgram; } + + getTsOptionDiagnostics(cancellationToken?: ts.CancellationToken| + undefined): ReadonlyArray { + return this.tsProgram.getOptionsDiagnostics(cancellationToken); + } + + getNgOptionDiagnostics(cancellationToken?: ts.CancellationToken| + undefined): ReadonlyArray { + return []; + } + + getTsSyntacticDiagnostics( + sourceFile?: ts.SourceFile|undefined, + cancellationToken?: ts.CancellationToken|undefined): ReadonlyArray { + return this.tsProgram.getSyntacticDiagnostics(sourceFile, cancellationToken); + } + + getNgStructuralDiagnostics(cancellationToken?: ts.CancellationToken| + undefined): ReadonlyArray { + return []; + } + + getTsSemanticDiagnostics( + sourceFile?: ts.SourceFile|undefined, + cancellationToken?: ts.CancellationToken|undefined): ReadonlyArray { + return this.tsProgram.getSemanticDiagnostics(sourceFile, cancellationToken); + } + + getNgSemanticDiagnostics( + fileName?: string|undefined, + cancellationToken?: ts.CancellationToken|undefined): ReadonlyArray { + return []; + } + + loadNgStructureAsync(): Promise { return Promise.resolve(); } + + listLazyRoutes(entryRoute?: string|undefined): api.LazyRoute[] { + throw new Error('Method not implemented.'); + } + + getLibrarySummaries(): Map { + throw new Error('Method not implemented.'); + } + + getEmittedGeneratedFiles(): Map { + throw new Error('Method not implemented.'); + } + + getEmittedSourceFiles(): Map { + throw new Error('Method not implemented.'); + } + + emit(opts?: { + emitFlags?: api.EmitFlags, + cancellationToken?: ts.CancellationToken, + customTransformers?: api.CustomTransformers, + emitCallback?: api.TsEmitCallback, + mergeEmitResultsCallback?: api.TsMergeEmitResultsCallback + }): ts.EmitResult { + const emitCallback = opts && opts.emitCallback || defaultEmitCallback; + const mergeEmitResultsCallback = opts && opts.mergeEmitResultsCallback || mergeEmitResults; + + const checker = this.tsProgram.getTypeChecker(); + + // Set up the IvyCompilation, which manages state for the Ivy transformer. + const adapters = [new InjectableCompilerAdapter(checker)]; + const compilation = new IvyCompilation(adapters, checker); + + // Analyze every source file in the program. + this.tsProgram.getSourceFiles() + .filter(file => !file.fileName.endsWith('.d.ts')) + .forEach(file => compilation.analyze(file)); + + // Since there is no .d.ts transformation API, .d.ts files are transformed during write. + const writeFile: ts.WriteFileCallback = + (fileName: string, data: string, writeByteOrderMark: boolean, + onError: ((message: string) => void) | undefined, + sourceFiles: ReadonlyArray) => { + if (fileName.endsWith('.d.ts')) { + data = sourceFiles.reduce( + (data, sf) => compilation.transformedDtsFor(sf.fileName, data), data); + } + this.host.writeFile(fileName, data, writeByteOrderMark, onError, sourceFiles); + }; + + + // Run the emit, including a custom transformer that will downlevel the Ivy decorators in code. + const emitResult = emitCallback({ + program: this.tsProgram, + host: this.host, + options: this.options, + emitOnlyDtsFiles: false, writeFile, + customTransformers: { + before: [ivyTransformFactory(compilation)], + }, + }); + return emitResult; + } +} + +const defaultEmitCallback: api.TsEmitCallback = + ({program, targetSourceFile, writeFile, cancellationToken, emitOnlyDtsFiles, + customTransformers}) => + program.emit( + targetSourceFile, writeFile, cancellationToken, emitOnlyDtsFiles, customTransformers); + +function mergeEmitResults(emitResults: ts.EmitResult[]): ts.EmitResult { + const diagnostics: ts.Diagnostic[] = []; + let emitSkipped = false; + const emittedFiles: string[] = []; + for (const er of emitResults) { + diagnostics.push(...er.diagnostics); + emitSkipped = emitSkipped || er.emitSkipped; + emittedFiles.push(...(er.emittedFiles || [])); + } + return {diagnostics, emitSkipped, emittedFiles}; +} diff --git a/packages/compiler-cli/src/ngtsc/transform/BUILD.bazel b/packages/compiler-cli/src/ngtsc/transform/BUILD.bazel new file mode 100644 index 0000000000..61232d4328 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/transform/BUILD.bazel @@ -0,0 +1,16 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "transform", + srcs = glob([ + "index.ts", + "src/**/*.ts", + ]), + module_name = "@angular/compiler-cli/src/ngtsc/transform", + deps = [ + "//packages/compiler", + "//packages/compiler-cli/src/ngtsc/metadata", + ], +) diff --git a/packages/compiler-cli/src/ngtsc/transform/index.ts b/packages/compiler-cli/src/ngtsc/transform/index.ts new file mode 100644 index 0000000000..bf776e1a2d --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/transform/index.ts @@ -0,0 +1,11 @@ +/** + * @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 + */ + +export {IvyCompilation} from './src/compilation'; +export {InjectableCompilerAdapter} from './src/injectable'; +export {ivyTransformFactory} from './src/transform'; diff --git a/packages/compiler-cli/src/ngtsc/transform/src/api.ts b/packages/compiler-cli/src/ngtsc/transform/src/api.ts new file mode 100644 index 0000000000..e1b63c19e0 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/transform/src/api.ts @@ -0,0 +1,61 @@ +/** + * @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 {Expression, Type} from '@angular/compiler'; +import * as ts from 'typescript'; + +import {Decorator} from '../../metadata'; + +/** + * Provides the interface between a decorator compiler from @angular/compiler and the Typescript + * compiler/transform. + * + * The decorator compilers in @angular/compiler do not depend on Typescript. The adapter is + * responsible for extracting the information required to perform compilation from the decorators + * and Typescript source, invoking the decorator compiler, and returning the result. + */ +export interface CompilerAdapter { + /** + * Scan a set of reflected decorators and determine if this adapter is responsible for compilation + * of one of them. + */ + detect(decorator: Decorator[]): Decorator|undefined; + + /** + * Perform analysis on the decorator/class combination, producing instructions for compilation + * if successful, or an array of diagnostic messages if the analysis fails or the decorator + * isn't valid. + */ + analyze(node: ts.ClassDeclaration, decorator: Decorator): AnalysisOutput; + + /** + * Generate a description of the field which should be added to the class, including any + * initialization code to be generated. + */ + compile(node: ts.ClassDeclaration, analysis: A): AddStaticFieldInstruction; +} + +/** + * The output of an analysis operation, consisting of possibly an arbitrary analysis object (used as + * the input to code generation) and potentially diagnostics if there were errors uncovered during + * analysis. + */ +export interface AnalysisOutput { + analysis?: A; + diagnostics?: ts.Diagnostic[]; +} + +/** + * A description of the static field to add to a class, including an initialization expression + * and a type for the .d.ts file. + */ +export interface AddStaticFieldInstruction { + field: string; + initializer: Expression; + type: Type; +} diff --git a/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts b/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts new file mode 100644 index 0000000000..45e39f552b --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts @@ -0,0 +1,157 @@ +/** + * @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 {Expression, Type} from '@angular/compiler'; +import * as ts from 'typescript'; + +import {Decorator, reflectDecorator} from '../../metadata'; + +import {AddStaticFieldInstruction, AnalysisOutput, CompilerAdapter} from './api'; +import {DtsFileTransformer} from './declaration'; +import {ImportManager, translateType} from './translator'; + + +/** + * Record of an adapter which decided to emit a static field, and the analysis it performed to + * prepare for that operation. + */ +interface EmitFieldOperation { + adapter: CompilerAdapter; + analysis: AnalysisOutput; + decorator: ts.Decorator; +} + +/** + * Manages a compilation of Ivy decorators into static fields across an entire ts.Program. + * + * The compilation is stateful - source files are analyzed and records of the operations that need + * to be performed during the transform/emit process are maintained internally. + */ +export class IvyCompilation { + /** + * Tracks classes which have been analyzed and found to have an Ivy decorator, and the + * information recorded about them for later compilation. + */ + private analysis = new Map>(); + + /** + * Tracks the `DtsFileTransformer`s for each TS file that needs .d.ts transformations. + */ + private dtsMap = new Map(); + + constructor(private adapters: CompilerAdapter[], private checker: ts.TypeChecker) {} + + /** + * Analyze a source file and produce diagnostics for it (if any). + */ + analyze(sf: ts.SourceFile): ts.Diagnostic[] { + const diagnostics: ts.Diagnostic[] = []; + const visit = (node: ts.Node) => { + // Process nodes recursively, and look for class declarations with decorators. + if (ts.isClassDeclaration(node) && node.decorators !== undefined) { + // The first step is to reflect the decorators, which will identify decorators + // that are imported from another module. + const decorators = + node.decorators.map(decorator => reflectDecorator(decorator, this.checker)) + .filter(decorator => decorator !== null) as Decorator[]; + + // Look through the CompilerAdapters to see if any are relevant. + this.adapters.forEach(adapter => { + // An adapter is relevant if it matches one of the decorators on the class. + const decorator = adapter.detect(decorators); + if (decorator === undefined) { + return; + } + + // Check for multiple decorators on the same node. Technically speaking this + // could be supported, but right now it's an error. + if (this.analysis.has(node)) { + throw new Error('TODO.Diagnostic: Class has multiple Angular decorators.'); + } + + // Run analysis on the decorator. This will produce either diagnostics, an + // analysis result, or both. + const analysis = adapter.analyze(node, decorator); + if (analysis.diagnostics !== undefined) { + diagnostics.push(...analysis.diagnostics); + } + if (analysis.analysis !== undefined) { + this.analysis.set(node, { + adapter, + analysis: analysis.analysis, + decorator: decorator.node, + }); + } + }); + } + + ts.forEachChild(node, visit); + }; + + visit(sf); + return diagnostics; + } + + /** + * Perform a compilation operation on the given class declaration and return instructions to an + * AST transformer if any are available. + */ + compileIvyFieldFor(node: ts.ClassDeclaration): AddStaticFieldInstruction|undefined { + // Look to see whether the original node was analyzed. If not, there's nothing to do. + const original = ts.getOriginalNode(node) as ts.ClassDeclaration; + if (!this.analysis.has(original)) { + return undefined; + } + const op = this.analysis.get(original) !; + + // Run the actual compilation, which generates an Expression for the Ivy field. + const res = op.adapter.compile(node, op.analysis); + + // Look up the .d.ts transformer for the input file and record that a field was generated, + // which will allow the .d.ts to be transformed later. + const fileName = node.getSourceFile().fileName; + const dtsTransformer = this.getDtsTransformer(fileName); + dtsTransformer.recordStaticField(node.name !.text, res); + + // Return the instruction to the transformer so the field will be added. + return res; + } + + /** + * Lookup the `ts.Decorator` which triggered transformation of a particular class declaration. + */ + ivyDecoratorFor(node: ts.ClassDeclaration): ts.Decorator|undefined { + const original = ts.getOriginalNode(node) as ts.ClassDeclaration; + if (!this.analysis.has(original)) { + return undefined; + } + + return this.analysis.get(original) !.decorator; + } + + /** + * Process a .d.ts source string and return a transformed version that incorporates the changes + * made to the source file. + */ + transformedDtsFor(tsFileName: string, dtsOriginalSource: string): string { + // No need to transform if no changes have been requested to the input file. + if (!this.dtsMap.has(tsFileName)) { + return dtsOriginalSource; + } + + // Return the transformed .d.ts source. + return this.dtsMap.get(tsFileName) !.transform(dtsOriginalSource); + } + + private getDtsTransformer(tsFileName: string): DtsFileTransformer { + if (!this.dtsMap.has(tsFileName)) { + this.dtsMap.set(tsFileName, new DtsFileTransformer()); + } + return this.dtsMap.get(tsFileName) !; + } +} diff --git a/packages/compiler-cli/src/ngtsc/transform/src/declaration.ts b/packages/compiler-cli/src/ngtsc/transform/src/declaration.ts new file mode 100644 index 0000000000..1e2c3d4abb --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/transform/src/declaration.ts @@ -0,0 +1,56 @@ +/** + * @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 {AddStaticFieldInstruction} from './api'; +import {ImportManager, translateType} from './translator'; + + + +/** + * Processes .d.ts file text and adds static field declarations, with types. + */ +export class DtsFileTransformer { + private ivyFields = new Map(); + private imports = new ImportManager(); + + /** + * Track that a static field was added to the code for a class. + */ + recordStaticField(name: string, decl: AddStaticFieldInstruction): void { + this.ivyFields.set(name, decl); + } + + /** + * Process the .d.ts text for a file and add any declarations which were recorded. + */ + transform(dts: string): string { + const dtsFile = + ts.createSourceFile('out.d.ts', dts, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); + + for (let i = dtsFile.statements.length - 1; i >= 0; i--) { + const stmt = dtsFile.statements[i]; + if (ts.isClassDeclaration(stmt) && stmt.name !== undefined && + this.ivyFields.has(stmt.name.text)) { + const desc = this.ivyFields.get(stmt.name.text) !; + const before = dts.substring(0, stmt.end - 1); + const after = dts.substring(stmt.end - 1); + const type = translateType(desc.type, this.imports); + dts = before + ` static ${desc.field}: ${type};\n` + after; + } + } + + const imports = this.imports.getAllImports(); + if (imports.length !== 0) { + dts = imports.map(i => `import * as ${i.as} from '${i.name}';\n`).join() + dts; + } + + return dts; + } +} \ No newline at end of file diff --git a/packages/compiler-cli/src/ngtsc/transform/src/injectable.ts b/packages/compiler-cli/src/ngtsc/transform/src/injectable.ts new file mode 100644 index 0000000000..62bebcfd19 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/transform/src/injectable.ts @@ -0,0 +1,181 @@ +/** + * @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 {Expression, IvyInjectableDep, IvyInjectableMetadata, LiteralExpr, WrappedNodeExpr, compileIvyInjectable} from '@angular/compiler'; +import * as ts from 'typescript'; + +import {Decorator} from '../../metadata'; +import {reflectConstructorParameters, reflectImportedIdentifier, reflectObjectLiteral} from '../../metadata/src/reflector'; + +import {AddStaticFieldInstruction, AnalysisOutput, CompilerAdapter} from './api'; + + +/** + * Adapts the `compileIvyInjectable` compiler for `@Injectable` decorators to the Ivy compiler. + */ +export class InjectableCompilerAdapter implements CompilerAdapter { + constructor(private checker: ts.TypeChecker) {} + + detect(decorator: Decorator[]): Decorator|undefined { + return decorator.find(dec => dec.name === 'Injectable' && dec.from === '@angular/core'); + } + + analyze(node: ts.ClassDeclaration, decorator: Decorator): AnalysisOutput { + return { + analysis: extractInjectableMetadata(node, decorator, this.checker), + }; + } + + compile(node: ts.ClassDeclaration, analysis: IvyInjectableMetadata): AddStaticFieldInstruction { + const res = compileIvyInjectable(analysis); + return { + field: 'ngInjectableDef', + initializer: res.expression, + type: res.type, + }; + } +} + +/** + * Read metadata from the `@Injectable` decorator and produce the `IvyInjectableMetadata`, the input + * metadata needed to run `compileIvyInjectable`. + */ +function extractInjectableMetadata( + clazz: ts.ClassDeclaration, decorator: Decorator, + checker: ts.TypeChecker): IvyInjectableMetadata { + if (clazz.name === undefined) { + throw new Error(`@Injectables must have names`); + } + const name = clazz.name.text; + const type = new WrappedNodeExpr(clazz.name); + if (decorator.args.length === 0) { + return { + name, + type, + providedIn: new LiteralExpr(null), + useType: getUseType(clazz, checker), + }; + } else if (decorator.args.length === 1) { + const metaNode = decorator.args[0]; + // Firstly make sure the decorator argument is an inline literal - if not, it's illegal to + // transport references from one location to another. This is the problem that lowering + // used to solve - if this restriction proves too undesirable we can re-implement lowering. + if (!ts.isObjectLiteralExpression(metaNode)) { + throw new Error(`In Ivy, decorator metadata must be inline.`); + } + + // Resolve the fields of the literal into a map of field name to expression. + const meta = reflectObjectLiteral(metaNode); + let providedIn: Expression = new LiteralExpr(null); + if (meta.has('providedIn')) { + providedIn = new WrappedNodeExpr(meta.get('providedIn') !); + } + if (meta.has('useValue')) { + return {name, type, providedIn, useValue: new WrappedNodeExpr(meta.get('useValue') !)}; + } else if (meta.has('useExisting')) { + return {name, type, providedIn, useExisting: new WrappedNodeExpr(meta.get('useExisting') !)}; + } else if (meta.has('useClass')) { + return {name, type, providedIn, useClass: new WrappedNodeExpr(meta.get('useClass') !)}; + } else if (meta.has('useFactory')) { + // useFactory is special - the 'deps' property must be analyzed. + const factory = new WrappedNodeExpr(meta.get('useFactory') !); + const deps: IvyInjectableDep[] = []; + if (meta.has('deps')) { + const depsExpr = meta.get('deps') !; + if (!ts.isArrayLiteralExpression(depsExpr)) { + throw new Error(`In Ivy, deps metadata must be inline.`); + } + if (depsExpr.elements.length > 0) { + throw new Error(`deps not yet supported`); + } + deps.push(...depsExpr.elements.map(dep => getDep(dep, checker))); + } + return {name, type, providedIn, useFactory: {factory, deps}}; + } else { + const useType = getUseType(clazz, checker); + return {name, type, providedIn, useType}; + } + } else { + throw new Error(`Too many arguments to @Injectable`); + } +} + +function getUseType(clazz: ts.ClassDeclaration, checker: ts.TypeChecker): IvyInjectableDep[] { + const useType: IvyInjectableDep[] = []; + const ctorParams = (reflectConstructorParameters(clazz, checker) || []); + ctorParams.forEach(param => { + let tokenExpr = param.typeValueExpr; + let optional = false, self = false, skipSelf = false; + param.decorators.filter(dec => dec.from === '@angular/core').forEach(dec => { + if (dec.name === 'Inject') { + if (dec.args.length !== 1) { + throw new Error(`Unexpected number of arguments to @Inject().`); + } + tokenExpr = dec.args[0]; + } else if (dec.name === 'Optional') { + optional = true; + } else if (dec.name === 'SkipSelf') { + skipSelf = true; + } else if (dec.name === 'Self') { + self = true; + } else { + throw new Error(`Unexpected decorator ${dec.name} on parameter.`); + } + if (tokenExpr === null) { + throw new Error(`No suitable token for parameter!`); + } + }); + const token = new WrappedNodeExpr(tokenExpr); + useType.push({token, optional, self, skipSelf}); + }); + return useType; +} + +function getDep(dep: ts.Expression, checker: ts.TypeChecker): IvyInjectableDep { + const depObj = { + token: new WrappedNodeExpr(dep), + optional: false, + self: false, + skipSelf: false, + }; + + function maybeUpdateDecorator(dec: ts.Identifier, token?: ts.Expression): void { + const source = reflectImportedIdentifier(dec, checker); + if (source === null || source.from !== '@angular/core') { + return; + } + switch (source.name) { + case 'Inject': + if (token !== undefined) { + depObj.token = new WrappedNodeExpr(token); + } + break; + case 'Optional': + depObj.optional = true; + break; + case 'SkipSelf': + depObj.skipSelf = true; + break; + case 'Self': + depObj.self = true; + break; + } + } + + if (ts.isArrayLiteralExpression(dep)) { + dep.elements.forEach(el => { + if (ts.isIdentifier(el)) { + maybeUpdateDecorator(el); + } else if (ts.isNewExpression(el) && ts.isIdentifier(el.expression)) { + const token = el.arguments && el.arguments.length > 0 && el.arguments[0] || undefined; + maybeUpdateDecorator(el.expression, token); + } + }); + } + return depObj; +} diff --git a/packages/compiler-cli/src/ngtsc/transform/src/transform.ts b/packages/compiler-cli/src/ngtsc/transform/src/transform.ts new file mode 100644 index 0000000000..161369cc4b --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/transform/src/transform.ts @@ -0,0 +1,96 @@ +/** + * @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 {WrappedNodeExpr, compileIvyInjectable} from '@angular/compiler'; +import * as ts from 'typescript'; + +import {IvyCompilation} from './compilation'; +import {ImportManager, translateExpression} from './translator'; + +export function ivyTransformFactory(compilation: IvyCompilation): + ts.TransformerFactory { + return (context: ts.TransformationContext): ts.Transformer => { + return (file: ts.SourceFile): ts.SourceFile => { + return transformIvySourceFile(compilation, context, file); + }; + }; +} + +/** + * A transformer which operates on ts.SourceFiles and applies changes from an `IvyCompilation`. + */ +function transformIvySourceFile( + compilation: IvyCompilation, context: ts.TransformationContext, + file: ts.SourceFile): ts.SourceFile { + const importManager = new ImportManager(); + + // Recursively scan through the AST and perform any updates requested by the IvyCompilation. + const sf = visitNode(file); + + // Generate the import statements to prepend. + const imports = importManager.getAllImports().map( + i => ts.createImportDeclaration( + undefined, undefined, + ts.createImportClause(undefined, ts.createNamespaceImport(ts.createIdentifier(i.as))), + ts.createLiteral(i.name))); + + // Prepend imports if needed. + if (imports.length > 0) { + sf.statements = ts.createNodeArray([...imports, ...sf.statements]); + } + return sf; + + // Helper function to process a class declaration. + function visitClassDeclaration(node: ts.ClassDeclaration): ts.ClassDeclaration { + // Determine if this class has an Ivy field that needs to be added, and compile the field + // to an expression if so. + const res = compilation.compileIvyFieldFor(node); + if (res !== undefined) { + // There is a field to add. Translate the initializer for the field into TS nodes. + const exprNode = translateExpression(res.initializer, importManager); + + // Create a static property declaration for the new field. + const property = ts.createProperty( + undefined, [ts.createToken(ts.SyntaxKind.StaticKeyword)], res.field, undefined, undefined, + exprNode); + + // Replace the class declaration with an updated version. + node = ts.updateClassDeclaration( + node, + // Remove the decorator which triggered this compilation, leaving the others alone. + maybeFilterDecorator(node.decorators, compilation.ivyDecoratorFor(node) !), + node.modifiers, node.name, node.typeParameters, node.heritageClauses || [], + [...node.members, property]); + } + + // Recurse into the class declaration in case there are nested class declarations. + return ts.visitEachChild(node, child => visitNode(child), context); + } + + // Helper function that recurses through the nodes and processes each one. + function visitNode(node: T): T; + function visitNode(node: ts.Node): ts.Node { + if (ts.isClassDeclaration(node)) { + return visitClassDeclaration(node); + } else { + return ts.visitEachChild(node, child => visitNode(child), context); + } + } +} +function maybeFilterDecorator( + decorators: ts.NodeArray| undefined, + toRemove: ts.Decorator): ts.NodeArray|undefined { + if (decorators === undefined) { + return undefined; + } + const filtered = decorators.filter(dec => ts.getOriginalNode(dec) !== toRemove); + if (filtered.length === 0) { + return undefined; + } + return ts.createNodeArray(filtered); +} diff --git a/packages/compiler-cli/src/ngtsc/transform/src/translator.ts b/packages/compiler-cli/src/ngtsc/transform/src/translator.ts new file mode 100644 index 0000000000..77151a94ba --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/transform/src/translator.ts @@ -0,0 +1,319 @@ +/** + * @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 {ArrayType, AssertNotNull, BinaryOperatorExpr, BuiltinType, BuiltinTypeName, CastExpr, ClassStmt, CommaExpr, CommentStmt, ConditionalExpr, DeclareFunctionStmt, DeclareVarStmt, Expression, ExpressionStatement, ExpressionType, ExpressionVisitor, ExternalExpr, ExternalReference, FunctionExpr, IfStmt, InstantiateExpr, InvokeFunctionExpr, InvokeMethodExpr, JSDocCommentStmt, LiteralArrayExpr, LiteralExpr, LiteralMapExpr, MapType, NotExpr, ReadKeyExpr, ReadPropExpr, ReadVarExpr, ReturnStatement, StatementVisitor, ThrowStmt, TryCatchStmt, Type, TypeVisitor, WrappedNodeExpr, WriteKeyExpr, WritePropExpr, WriteVarExpr} from '@angular/compiler'; +import * as ts from 'typescript'; + +export class ImportManager { + private moduleToIndex = new Map(); + private nextIndex = 0; + + generateNamedImport(moduleName: string): string { + if (!this.moduleToIndex.has(moduleName)) { + this.moduleToIndex.set(moduleName, `i${this.nextIndex++}`); + } + return this.moduleToIndex.get(moduleName) !; + } + + getAllImports(): {name: string, as: string}[] { + return Array.from(this.moduleToIndex.keys()).map(name => { + const as = this.moduleToIndex.get(name) !; + return {name, as}; + }); + } +} + +export function translateExpression(expression: Expression, imports: ImportManager): ts.Expression { + return expression.visitExpression(new ExpressionTranslatorVisitor(imports), null); +} + +export function translateType(type: Type, imports: ImportManager): string { + return type.visitType(new TypeTranslatorVisitor(imports), null); +} + +class ExpressionTranslatorVisitor implements ExpressionVisitor, StatementVisitor { + constructor(private imports: ImportManager) {} + + visitDeclareVarStmt(stmt: DeclareVarStmt, context: any) { + throw new Error('Method not implemented.'); + } + + visitDeclareFunctionStmt(stmt: DeclareFunctionStmt, context: any) { + throw new Error('Method not implemented.'); + } + + visitExpressionStmt(stmt: ExpressionStatement, context: any) { + throw new Error('Method not implemented.'); + } + + visitReturnStmt(stmt: ReturnStatement, context: any): ts.ReturnStatement { + return ts.createReturn(stmt.value.visitExpression(this, context)); + } + + visitDeclareClassStmt(stmt: ClassStmt, context: any) { + throw new Error('Method not implemented.'); + } + + visitIfStmt(stmt: IfStmt, context: any) { throw new Error('Method not implemented.'); } + + visitTryCatchStmt(stmt: TryCatchStmt, context: any) { + throw new Error('Method not implemented.'); + } + + visitThrowStmt(stmt: ThrowStmt, context: any) { throw new Error('Method not implemented.'); } + + visitCommentStmt(stmt: CommentStmt, context: any): never { + throw new Error('Method not implemented.'); + } + + visitJSDocCommentStmt(stmt: JSDocCommentStmt, context: any): never { + throw new Error('Method not implemented.'); + } + + visitReadVarExpr(ast: ReadVarExpr, context: any): ts.Identifier { + return ts.createIdentifier(ast.name !); + } + + visitWriteVarExpr(expr: WriteVarExpr, context: any): ts.BinaryExpression { + return ts.createBinary( + ts.createIdentifier(expr.name), ts.SyntaxKind.EqualsToken, + expr.value.visitExpression(this, context)); + } + + visitWriteKeyExpr(expr: WriteKeyExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitWritePropExpr(expr: WritePropExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitInvokeMethodExpr(ast: InvokeMethodExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitInvokeFunctionExpr(ast: InvokeFunctionExpr, context: any): ts.CallExpression { + return ts.createCall( + ast.fn.visitExpression(this, context), undefined, + ast.args.map(arg => arg.visitExpression(this, context))); + } + + visitInstantiateExpr(ast: InstantiateExpr, context: any): ts.NewExpression { + return ts.createNew( + ast.classExpr.visitExpression(this, context), undefined, + ast.args.map(arg => arg.visitExpression(this, context))); + } + + visitLiteralExpr(ast: LiteralExpr, context: any): ts.Expression { + if (ast.value === undefined) { + return ts.createIdentifier('undefined'); + } else if (ast.value === null) { + return ts.createNull(); + } else { + return ts.createLiteral(ast.value); + } + } + + visitExternalExpr(ast: ExternalExpr, context: any): ts.PropertyAccessExpression { + if (ast.value.moduleName === null || ast.value.name === null) { + throw new Error(`Import unknown module or symbol ${ast.value}`); + } + return ts.createPropertyAccess( + ts.createIdentifier(this.imports.generateNamedImport(ast.value.moduleName)), + ts.createIdentifier(ast.value.name)); + } + + visitConditionalExpr(ast: ConditionalExpr, context: any): ts.ParenthesizedExpression { + return ts.createParen(ts.createConditional( + ast.condition.visitExpression(this, context), ast.trueCase.visitExpression(this, context), + ast.falseCase !.visitExpression(this, context))); + } + + visitNotExpr(ast: NotExpr, context: any): ts.PrefixUnaryExpression { + return ts.createPrefix( + ts.SyntaxKind.ExclamationToken, ast.condition.visitExpression(this, context)); + } + + visitAssertNotNullExpr(ast: AssertNotNull, context: any): ts.NonNullExpression { + return ts.createNonNullExpression(ast.condition.visitExpression(this, context)); + } + + visitCastExpr(ast: CastExpr, context: any): ts.Expression { + return ast.value.visitExpression(this, context); + } + + visitFunctionExpr(ast: FunctionExpr, context: any): ts.FunctionExpression { + return ts.createFunctionExpression( + undefined, undefined, ast.name || undefined, undefined, + ast.params.map( + param => ts.createParameter( + undefined, undefined, undefined, param.name, undefined, undefined, undefined)), + undefined, ts.createBlock(ast.statements.map(stmt => stmt.visitStatement(this, context)))); + } + + visitBinaryOperatorExpr(ast: BinaryOperatorExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitReadPropExpr(ast: ReadPropExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitReadKeyExpr(ast: ReadKeyExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitLiteralArrayExpr(ast: LiteralArrayExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitLiteralMapExpr(ast: LiteralMapExpr, context: any): ts.ObjectLiteralExpression { + const entries = ast.entries.map( + entry => ts.createPropertyAssignment( + entry.quoted ? ts.createLiteral(entry.key) : ts.createIdentifier(entry.key), + entry.value.visitExpression(this, context))); + return ts.createObjectLiteral(entries); + } + + visitCommaExpr(ast: CommaExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitWrappedNodeExpr(ast: WrappedNodeExpr, context: any): any { return ast.node; } +} + +export class TypeTranslatorVisitor implements ExpressionVisitor, TypeVisitor { + constructor(private imports: ImportManager) {} + + visitBuiltinType(type: BuiltinType, context: any): string { + switch (type.name) { + case BuiltinTypeName.Bool: + return 'boolean'; + case BuiltinTypeName.Dynamic: + return 'any'; + case BuiltinTypeName.Int: + case BuiltinTypeName.Number: + return 'number'; + case BuiltinTypeName.String: + return 'string'; + default: + throw new Error(`Unsupported builtin type: ${BuiltinTypeName[type.name]}`); + } + } + + visitExpressionType(type: ExpressionType, context: any): any { + return type.value.visitExpression(this, context); + } + + visitArrayType(type: ArrayType, context: any): string { + return `Array<${type.visitType(this, context)}>`; + } + + visitMapType(type: MapType, context: any): string { + if (type.valueType !== null) { + return `{[key: string]: ${type.valueType.visitType(this, context)}}`; + } else { + return '{[key: string]: any}'; + } + } + + visitReadVarExpr(ast: ReadVarExpr, context: any): string { + if (ast.name === null) { + throw new Error(`ReadVarExpr with no variable name in type`); + } + return ast.name; + } + + visitWriteVarExpr(expr: WriteVarExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitWriteKeyExpr(expr: WriteKeyExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitWritePropExpr(expr: WritePropExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitInvokeMethodExpr(ast: InvokeMethodExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitInvokeFunctionExpr(ast: InvokeFunctionExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitInstantiateExpr(ast: InstantiateExpr, context: any): never { + throw new Error('Method not implemented.'); + } + + visitLiteralExpr(ast: LiteralExpr, context: any): string { + if (typeof ast.value === 'string') { + const escaped = ast.value.replace(/\'/g, '\\\''); + return `'${escaped}'`; + } else { + return `${ast.value}`; + } + } + + visitExternalExpr(ast: ExternalExpr, context: any): string { + if (ast.value.moduleName === null || ast.value.name === null) { + throw new Error(`Import unknown module or symbol`); + } + const base = `${this.imports.generateNamedImport(ast.value.moduleName)}.${ast.value.name}`; + if (ast.typeParams !== null) { + const generics = ast.typeParams.map(type => type.visitType(this, context)).join(', '); + return `${base}<${generics}>`; + } else { + return base; + } + } + + visitConditionalExpr(ast: ConditionalExpr, context: any) { + throw new Error('Method not implemented.'); + } + + visitNotExpr(ast: NotExpr, context: any) { throw new Error('Method not implemented.'); } + + visitAssertNotNullExpr(ast: AssertNotNull, context: any) { + throw new Error('Method not implemented.'); + } + + visitCastExpr(ast: CastExpr, context: any) { throw new Error('Method not implemented.'); } + + visitFunctionExpr(ast: FunctionExpr, context: any) { throw new Error('Method not implemented.'); } + + visitBinaryOperatorExpr(ast: BinaryOperatorExpr, context: any) { + throw new Error('Method not implemented.'); + } + + visitReadPropExpr(ast: ReadPropExpr, context: any) { throw new Error('Method not implemented.'); } + + visitReadKeyExpr(ast: ReadKeyExpr, context: any) { throw new Error('Method not implemented.'); } + + visitLiteralArrayExpr(ast: LiteralArrayExpr, context: any) { + throw new Error('Method not implemented.'); + } + + visitLiteralMapExpr(ast: LiteralMapExpr, context: any) { + throw new Error('Method not implemented.'); + } + + visitCommaExpr(ast: CommaExpr, context: any) { throw new Error('Method not implemented.'); } + + visitWrappedNodeExpr(ast: WrappedNodeExpr, context: any) { + const node: ts.Node = ast.node; + if (ts.isIdentifier(node)) { + return node.text; + } else { + throw new Error( + `Unsupported WrappedNodeExpr in TypeTranslatorVisitor: ${ts.SyntaxKind[node.kind]}`); + } + } +} \ No newline at end of file diff --git a/packages/compiler-cli/src/transformers/api.ts b/packages/compiler-cli/src/transformers/api.ts index 2153052497..ce1cd4e233 100644 --- a/packages/compiler-cli/src/transformers/api.ts +++ b/packages/compiler-cli/src/transformers/api.ts @@ -184,7 +184,7 @@ export interface CompilerOptions extends ts.CompilerOptions { * * @experimental */ - enableIvy?: boolean; + enableIvy?: boolean|'ngtsc'; /** @internal */ collectAllErrors?: boolean; diff --git a/packages/compiler-cli/src/transformers/compiler_host.ts b/packages/compiler-cli/src/transformers/compiler_host.ts index 963693257d..972313ddd8 100644 --- a/packages/compiler-cli/src/transformers/compiler_host.ts +++ b/packages/compiler-cli/src/transformers/compiler_host.ts @@ -12,6 +12,7 @@ import * as ts from 'typescript'; import {TypeCheckHost} from '../diagnostics/translate_diagnostics'; import {METADATA_VERSION, ModuleMetadata} from '../metadata/index'; +import {NgtscCompilerHost} from '../ngtsc/compiler_host'; import {CompilerHost, CompilerOptions, LibrarySummary} from './api'; import {MetadataReaderHost, createMetadataReaderCache, readMetadata} from './metadata_reader'; @@ -23,6 +24,9 @@ const EXT = /(\.ts|\.d\.ts|\.js|\.jsx|\.tsx)$/; export function createCompilerHost( {options, tsHost = ts.createCompilerHost(options, true)}: {options: CompilerOptions, tsHost?: ts.CompilerHost}): CompilerHost { + if (options.enableIvy) { + return new NgtscCompilerHost(tsHost); + } return tsHost; } diff --git a/packages/compiler-cli/src/transformers/node_emitter.ts b/packages/compiler-cli/src/transformers/node_emitter.ts index 7981b3b01a..35fba0943f 100644 --- a/packages/compiler-cli/src/transformers/node_emitter.ts +++ b/packages/compiler-cli/src/transformers/node_emitter.ts @@ -6,8 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import {AssertNotNull, BinaryOperator, BinaryOperatorExpr, BuiltinMethod, BuiltinVar, CastExpr, ClassStmt, CommaExpr, CommentStmt, ConditionalExpr, DeclareFunctionStmt, DeclareVarStmt, ExpressionStatement, ExpressionVisitor, ExternalExpr, ExternalReference, FunctionExpr, IfStmt, InstantiateExpr, InvokeFunctionExpr, InvokeMethodExpr, JSDocCommentStmt, LiteralArrayExpr, LiteralExpr, LiteralMapExpr, NotExpr, ParseSourceFile, ParseSourceSpan, PartialModule, ReadKeyExpr, ReadPropExpr, ReadVarExpr, ReturnStatement, Statement, StatementVisitor, StmtModifier, ThrowStmt, TryCatchStmt, WriteKeyExpr, WritePropExpr, WriteVarExpr} from '@angular/compiler'; +import {AssertNotNull, BinaryOperator, BinaryOperatorExpr, BuiltinMethod, BuiltinVar, CastExpr, ClassStmt, CommaExpr, CommentStmt, ConditionalExpr, DeclareFunctionStmt, DeclareVarStmt, ExpressionStatement, ExpressionVisitor, ExternalExpr, ExternalReference, FunctionExpr, IfStmt, InstantiateExpr, InvokeFunctionExpr, InvokeMethodExpr, JSDocCommentStmt, LiteralArrayExpr, LiteralExpr, LiteralMapExpr, NotExpr, ParseSourceFile, ParseSourceSpan, PartialModule, ReadKeyExpr, ReadPropExpr, ReadVarExpr, ReturnStatement, Statement, StatementVisitor, StmtModifier, ThrowStmt, TryCatchStmt, WrappedNodeExpr, WriteKeyExpr, WritePropExpr, WriteVarExpr} from '@angular/compiler'; import * as ts from 'typescript'; + import {error} from './util'; export interface Node { sourceSpan: ParseSourceSpan|null; } @@ -20,7 +21,7 @@ const _VALID_IDENTIFIER_RE = /^[$A-Z_][0-9A-Z_$]*$/i; export class TypeScriptNodeEmitter { updateSourceFile(sourceFile: ts.SourceFile, stmts: Statement[], preamble?: string): [ts.SourceFile, Map] { - const converter = new _NodeEmitterVisitor(); + const converter = new NodeEmitterVisitor(); // [].concat flattens the result so that each `visit...` method can also return an array of // stmts. const statements: any[] = [].concat( @@ -62,7 +63,7 @@ export class TypeScriptNodeEmitter { export function updateSourceFile( sourceFile: ts.SourceFile, module: PartialModule, context: ts.TransformationContext): [ts.SourceFile, Map] { - const converter = new _NodeEmitterVisitor(); + const converter = new NodeEmitterVisitor(); converter.loadExportedVariableIdentifiers(sourceFile); const prefixStatements = module.statements.filter(statement => !(statement instanceof ClassStmt)); @@ -148,7 +149,7 @@ function firstAfter(a: T[], predicate: (value: T) => boolean) { // A recorded node is a subtype of the node that is marked as being recorded. This is used // to ensure that NodeEmitterVisitor.record has been called on all nodes returned by the // NodeEmitterVisitor -type RecordedNode = (T & { __recorded: any; }) | null; +export type RecordedNode = (T & { __recorded: any;}) | null; function escapeLiteral(value: string): string { return value.replace(/(\"|\\)/g, '\\$1').replace(/(\n)|(\r)/g, function(v, n, r) { @@ -183,7 +184,7 @@ function isExportTypeStatement(statement: ts.Statement): boolean { /** * Visits an output ast and produces the corresponding TypeScript synthetic nodes. */ -class _NodeEmitterVisitor implements StatementVisitor, ExpressionVisitor { +export class NodeEmitterVisitor implements StatementVisitor, ExpressionVisitor { private _nodeMap = new Map(); private _importsWithPrefixes = new Map(); private _reexports = new Map(); @@ -461,6 +462,9 @@ class _NodeEmitterVisitor implements StatementVisitor, ExpressionVisitor { return commentStmt; } + // ExpressionVisitor + visitWrappedNodeExpr(expr: WrappedNodeExpr) { return this.record(expr, expr.node); } + // ExpressionVisitor visitReadVarExpr(expr: ReadVarExpr) { switch (expr.builtin) { diff --git a/packages/compiler-cli/src/transformers/program.ts b/packages/compiler-cli/src/transformers/program.ts index ca7f8a587e..f58dd7d465 100644 --- a/packages/compiler-cli/src/transformers/program.ts +++ b/packages/compiler-cli/src/transformers/program.ts @@ -15,6 +15,7 @@ import * as ts from 'typescript'; import {TypeCheckHost, translateDiagnostics} from '../diagnostics/translate_diagnostics'; import {compareVersions} from '../diagnostics/typescript_version'; import {MetadataCollector, ModuleMetadata, createBundleIndexHost} from '../metadata/index'; +import {NgtscProgram} from '../ngtsc/program'; import {CompilerHost, CompilerOptions, CustomTransformers, DEFAULT_ERROR_CODE, Diagnostic, DiagnosticMessageChain, EmitFlags, LazyRoute, LibrarySummary, Program, SOURCE, TsEmitArguments, TsEmitCallback, TsMergeEmitResultsCallback} from './api'; import {CodeGenerator, TsCompilerAotCompilerTypeCheckHostAdapter, getOriginalReferences} from './compiler_host'; @@ -286,6 +287,9 @@ class AngularCompilerProgram implements Program { emitCallback?: TsEmitCallback, mergeEmitResultsCallback?: TsMergeEmitResultsCallback, } = {}): ts.EmitResult { + if (this.options.enableIvy === 'ngtsc') { + throw new Error('Cannot run legacy compiler in ngtsc mode'); + } return this.options.enableIvy === true ? this._emitRender3(parameters) : this._emitRender2(parameters); } @@ -903,6 +907,9 @@ export function createProgram({rootNames, options, host, oldProgram}: { options: CompilerOptions, host: CompilerHost, oldProgram?: Program }): Program { + if (options.enableIvy === 'ngtsc') { + return new NgtscProgram(rootNames, options, host, oldProgram); + } return new AngularCompilerProgram(rootNames, options, host, oldProgram); } diff --git a/packages/compiler-cli/test/ngtsc/BUILD.bazel b/packages/compiler-cli/test/ngtsc/BUILD.bazel new file mode 100644 index 0000000000..a7bbbb77a3 --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/BUILD.bazel @@ -0,0 +1,27 @@ +load("//tools:defaults.bzl", "ts_library", "ts_web_test") +load("@build_bazel_rules_nodejs//:defs.bzl", "jasmine_node_test") + +ts_library( + name = "ngtsc_lib", + testonly = 1, + srcs = [ + "ngtsc_spec.ts", + ], + deps = [ + "//packages/compiler", + "//packages/compiler-cli", + "//packages/compiler-cli/test:test_utils", + ], +) + +jasmine_node_test( + name = "ngtsc", + bootstrap = ["angular/tools/testing/init_node_no_angular_spec.js"], + data = [ + "//packages/compiler-cli/test/ngtsc/fake_core:npm_package", + ], + deps = [ + ":ngtsc_lib", + "//tools/testing:node_no_angular", + ], +) diff --git a/packages/compiler-cli/test/ngtsc/fake_core/BUILD.bazel b/packages/compiler-cli/test/ngtsc/fake_core/BUILD.bazel new file mode 100644 index 0000000000..9914a4e07f --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/fake_core/BUILD.bazel @@ -0,0 +1,22 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ts_library", "ng_package") + +ts_library( + name = "fake_core", + srcs = [ + "index.ts", + ], + module_name = "@angular/core", +) + +ng_package( + name = "npm_package", + srcs = [ + "package.json", + ], + entry_point = "packages/fake_core/index.js", + deps = [ + ":fake_core", + ], +) diff --git a/packages/compiler-cli/test/ngtsc/fake_core/README.md b/packages/compiler-cli/test/ngtsc/fake_core/README.md new file mode 100644 index 0000000000..48869a674d --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/fake_core/README.md @@ -0,0 +1,13 @@ +`fake_core` is a library designed to expose some of the same symbols as `@angular/core`, without +requiring compilation of the whole of `@angular/core`. This enables unit tests for the compiler to +be written without incurring long rebuilds for every change. + +* `@angular/core` is compiled with `@angular/compiler-cli`, and therefore has an implicit dependency +on it. Therefore core must be rebuilt if the compiler changes. +* Tests for the compiler which intend to build code that depends on `@angular/core` must have +a data dependency on `@angular/core`. Therefore core must be built to run the compiler tests, and +thus rebuilt if the compiler changes. + +This rebuild cycle is expensive and slow. `fake_core` avoids this by exposing a subset of the +`@angular/core` API, which enables applications to be built by the ngtsc compiler without +needing a full version of core present at compile time. diff --git a/packages/compiler-cli/test/ngtsc/fake_core/index.ts b/packages/compiler-cli/test/ngtsc/fake_core/index.ts new file mode 100644 index 0000000000..c7d1c3c5b3 --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/fake_core/index.ts @@ -0,0 +1,25 @@ +/** + * @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 + */ + +type FnWithArg = (arg?: any) => T; + +function callableClassDecorator(): FnWithArg<(clazz: any) => any> { + return null !; +} + +function callableParamDecorator(): FnWithArg<(a: any, b: any, c: any) => void> { + return null !; +} + +export const Injectable = callableClassDecorator(); +export const NgModule = callableClassDecorator(); + +export const Inject = callableParamDecorator(); +export const Self = callableParamDecorator(); +export const SkipSelf = callableParamDecorator(); +export const Optional = callableParamDecorator(); diff --git a/packages/compiler-cli/test/ngtsc/fake_core/package.json b/packages/compiler-cli/test/ngtsc/fake_core/package.json new file mode 100644 index 0000000000..e26b2be3d8 --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/fake_core/package.json @@ -0,0 +1,13 @@ +{ + "name": "@angular/core", + "version": "0.0.0-FAKE-FOR-TESTS", + "description": "Fake version of core for ngtsc tests", + "main": "./bundles/fake_core.umd.js", + "module": "./fesm5/fake_core.js", + "es2015": "./fesm2015/fake_core.js", + "esm5": "./esm5/index.js", + "esm2015": "./esm2015/index.js", + "fesm5": "./fesm5/fake_core.js", + "fesm2015": "./fesm2015/fake_core.js", + "typings": "./index.d.ts" +} \ No newline at end of file diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts new file mode 100644 index 0000000000..4c56ce857d --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -0,0 +1,125 @@ +/** + * @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 fs from 'fs'; +import * as path from 'path'; +import * as ts from 'typescript'; + +import {main, readCommandLineAndConfiguration, watchMode} from '../../src/main'; +import {TestSupport, isInBazel, makeTempDir, setup} from '../test_support'; + +function setupFakeCore(support: TestSupport): void { + const fakeCore = path.join( + process.env.TEST_SRCDIR, 'angular/packages/compiler-cli/test/ngtsc/fake_core/npm_package'); + + const nodeModulesPath = path.join(support.basePath, 'node_modules'); + const angularCoreDirectory = path.join(nodeModulesPath, '@angular/core'); + + fs.symlinkSync(fakeCore, angularCoreDirectory); +} + +function getNgRootDir() { + const moduleFilename = module.filename.replace(/\\/g, '/'); + const distIndex = moduleFilename.indexOf('/dist/all'); + return moduleFilename.substr(0, distIndex); +} + +describe('ngtsc behavioral tests', () => { + if (!isInBazel()) { + // These tests should be excluded from the non-Bazel build. + return; + } + + let basePath: string; + let outDir: string; + let write: (fileName: string, content: string) => void; + let errorSpy: jasmine.Spy&((s: string) => void); + + function shouldExist(fileName: string) { + if (!fs.existsSync(path.resolve(outDir, fileName))) { + throw new Error(`Expected ${fileName} to be emitted (outDir: ${outDir})`); + } + } + + function shouldNotExist(fileName: string) { + if (fs.existsSync(path.resolve(outDir, fileName))) { + throw new Error(`Did not expect ${fileName} to be emitted (outDir: ${outDir})`); + } + } + + function getContents(fileName: string): string { + shouldExist(fileName); + const modulePath = path.resolve(outDir, fileName); + return fs.readFileSync(modulePath, 'utf8'); + } + + function writeConfig( + tsconfig: string = + '{"extends": "./tsconfig-base.json", "angularCompilerOptions": {"enableIvy": "ngtsc"}}') { + write('tsconfig.json', tsconfig); + } + + beforeEach(() => { + errorSpy = jasmine.createSpy('consoleError').and.callFake(console.error); + const support = setup(); + basePath = support.basePath; + outDir = path.join(basePath, 'built'); + process.chdir(basePath); + write = (fileName: string, content: string) => { support.write(fileName, content); }; + + setupFakeCore(support); + write('tsconfig-base.json', `{ + "compilerOptions": { + "experimentalDecorators": true, + "skipLibCheck": true, + "noImplicitAny": true, + "types": [], + "outDir": "built", + "rootDir": ".", + "baseUrl": ".", + "declaration": true, + "target": "es5", + "module": "es2015", + "moduleResolution": "node", + "lib": ["es6", "dom"], + "typeRoots": ["node_modules/@types"] + }, + "angularCompilerOptions": { + "enableIvy": "ngtsc" + } + }`); + }); + + it('should compile without errors', () => { + writeConfig(); + write('test.ts', ` + import {Injectable} from '@angular/core'; + + @Injectable() + export class Dep {} + + @Injectable() + export class Service { + constructor(dep: Dep) {} + } + `); + + const exitCode = main(['-p', basePath], errorSpy); + expect(errorSpy).not.toHaveBeenCalled(); + expect(exitCode).toBe(0); + + + const jsContents = getContents('test.js'); + expect(jsContents).toContain('Dep.ngInjectableDef ='); + expect(jsContents).toContain('Service.ngInjectableDef ='); + expect(jsContents).not.toContain('__decorate'); + const dtsContents = getContents('test.d.ts'); + expect(dtsContents).toContain('static ngInjectableDef: i0.InjectableDef;'); + expect(dtsContents).toContain('static ngInjectableDef: i0.InjectableDef;'); + }); +}); diff --git a/packages/compiler/src/aot/compiler_options.ts b/packages/compiler/src/aot/compiler_options.ts index b8d98cee40..10f87a333c 100644 --- a/packages/compiler/src/aot/compiler_options.ts +++ b/packages/compiler/src/aot/compiler_options.ts @@ -18,5 +18,5 @@ export interface AotCompilerOptions { fullTemplateTypeCheck?: boolean; allowEmptyCodegenFiles?: boolean; strictInjectionParameters?: boolean; - enableIvy?: boolean; + enableIvy?: boolean|'ngtsc'; } diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index e0bcadf329..a0f1c7cbe3 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -67,7 +67,7 @@ export * from './ml_parser/html_tags'; export * from './ml_parser/interpolation_config'; export * from './ml_parser/tags'; export {NgModuleCompiler} from './ng_module_compiler'; -export {AssertNotNull, BinaryOperator, BinaryOperatorExpr, BuiltinMethod, BuiltinVar, CastExpr, ClassField, ClassMethod, ClassStmt, CommaExpr, CommentStmt, ConditionalExpr, DeclareFunctionStmt, DeclareVarStmt, ExpressionStatement, ExpressionVisitor, ExternalExpr, ExternalReference, FunctionExpr, IfStmt, InstantiateExpr, InvokeFunctionExpr, InvokeMethodExpr, JSDocCommentStmt, LiteralArrayExpr, LiteralExpr, LiteralMapExpr, NotExpr, ReadKeyExpr, ReadPropExpr, ReadVarExpr, ReturnStatement, StatementVisitor, ThrowStmt, TryCatchStmt, WriteKeyExpr, WritePropExpr, WriteVarExpr, StmtModifier, Statement, collectExternalReferences} from './output/output_ast'; +export {ArrayType, AssertNotNull, BinaryOperator, BinaryOperatorExpr, BuiltinMethod, BuiltinType, BuiltinTypeName, BuiltinVar, CastExpr, ClassField, ClassMethod, ClassStmt, CommaExpr, CommentStmt, ConditionalExpr, DeclareFunctionStmt, DeclareVarStmt, Expression, ExpressionStatement, ExpressionType, ExpressionVisitor, ExternalExpr, ExternalReference, FunctionExpr, IfStmt, InstantiateExpr, InvokeFunctionExpr, InvokeMethodExpr, JSDocCommentStmt, LiteralArrayExpr, LiteralExpr, LiteralMapExpr, MapType, NotExpr, ReadKeyExpr, ReadPropExpr, ReadVarExpr, ReturnStatement, StatementVisitor, ThrowStmt, TryCatchStmt, Type, TypeVisitor, WrappedNodeExpr, WriteKeyExpr, WritePropExpr, WriteVarExpr, StmtModifier, Statement, collectExternalReferences} from './output/output_ast'; export {EmitterVisitorContext} from './output/abstract_emitter'; export * from './output/ts_emitter'; export * from './parse_util'; @@ -78,4 +78,5 @@ export * from './template_parser/template_parser'; export {ViewCompiler} from './view_compiler/view_compiler'; export {getParseErrors, isSyntaxError, syntaxError, Version} from './util'; export {SourceMap} from './output/source_map'; -// This file only reexports content of the `src` folder. Keep it that way. +export * from './injectable_compiler_2'; +// This file only reexports content of the `src` folder. Keep it that way. \ No newline at end of file diff --git a/packages/compiler/src/constant_pool.ts b/packages/compiler/src/constant_pool.ts index 794ba9e240..bd5a9b60cc 100644 --- a/packages/compiler/src/constant_pool.ts +++ b/packages/compiler/src/constant_pool.ts @@ -295,6 +295,7 @@ class KeyVisitor implements o.ExpressionVisitor { `EX:${ast.value.runtime.name}`; } + visitWrappedNodeExpr = invalid; visitReadVarExpr = invalid; visitWriteVarExpr = invalid; visitWriteKeyExpr = invalid; diff --git a/packages/compiler/src/injectable_compiler_2.ts b/packages/compiler/src/injectable_compiler_2.ts new file mode 100644 index 0000000000..a29d18f404 --- /dev/null +++ b/packages/compiler/src/injectable_compiler_2.ts @@ -0,0 +1,101 @@ +/** + * @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 {InjectFlags} from './core'; +import * as o from './output/output_ast'; +import {Identifiers} from './render3/r3_identifiers'; + + +type MapEntry = { + key: string; quoted: boolean; value: o.Expression; +}; + +function mapToMapExpression(map: {[key: string]: o.Expression}): o.LiteralMapExpr { + const result = Object.keys(map).map(key => ({key, value: map[key], quoted: false})); + return o.literalMap(result); +} + +export interface InjectableDef { + expression: o.Expression; + type: o.Type; +} + +export interface IvyInjectableDep { + token: o.Expression; + optional: boolean; + self: boolean; + skipSelf: boolean; +} + +export interface IvyInjectableMetadata { + name: string; + type: o.Expression; + providedIn: o.Expression; + useType?: IvyInjectableDep[]; + useClass?: o.Expression; + useFactory?: {factory: o.Expression; deps: IvyInjectableDep[];}; + useExisting?: o.Expression; + useValue?: o.Expression; +} + +export function compileIvyInjectable(meta: IvyInjectableMetadata): InjectableDef { + let ret: o.Expression = o.NULL_EXPR; + if (meta.useType !== undefined) { + const args = meta.useType.map(dep => injectDep(dep)); + ret = new o.InstantiateExpr(meta.type, args); + } else if (meta.useClass !== undefined) { + const factory = + new o.ReadPropExpr(new o.ReadPropExpr(meta.useClass, 'ngInjectableDef'), 'factory'); + ret = new o.InvokeFunctionExpr(factory, []); + } else if (meta.useValue !== undefined) { + ret = meta.useValue; + } else if (meta.useExisting !== undefined) { + ret = o.importExpr(Identifiers.inject).callFn([meta.useExisting]); + } else if (meta.useFactory !== undefined) { + const args = meta.useFactory.deps.map(dep => injectDep(dep)); + ret = new o.InvokeFunctionExpr(meta.useFactory.factory, args); + } else { + throw new Error('No instructions for injectable compiler!'); + } + + const token = meta.type; + const providedIn = meta.providedIn; + const factory = + o.fn([], [new o.ReturnStatement(ret)], undefined, undefined, `${meta.name}_Factory`); + + const expression = o.importExpr({ + moduleName: '@angular/core', + name: 'defineInjectable', + }).callFn([mapToMapExpression({token, factory, providedIn})]); + const type = new o.ExpressionType(o.importExpr( + { + moduleName: '@angular/core', + name: 'InjectableDef', + }, + [new o.ExpressionType(meta.type)])); + + return { + expression, type, + }; +} + +function injectDep(dep: IvyInjectableDep): o.Expression { + const defaultValue = dep.optional ? o.NULL_EXPR : o.literal(undefined); + const flags = o.literal( + InjectFlags.Default | (dep.self && InjectFlags.Self || 0) | + (dep.skipSelf && InjectFlags.SkipSelf || 0)); + if (!dep.optional && !dep.skipSelf && !dep.self) { + return o.importExpr(Identifiers.inject).callFn([dep.token]); + } else { + return o.importExpr(Identifiers.inject).callFn([ + dep.token, + defaultValue, + flags, + ]); + } +} diff --git a/packages/compiler/src/output/abstract_emitter.ts b/packages/compiler/src/output/abstract_emitter.ts index 8a49a96aa5..e168a5a28f 100644 --- a/packages/compiler/src/output/abstract_emitter.ts +++ b/packages/compiler/src/output/abstract_emitter.ts @@ -312,6 +312,9 @@ export abstract class AbstractEmitterVisitor implements o.StatementVisitor, o.Ex ctx.print(expr, `)`); return null; } + visitWrappedNodeExpr(ast: o.WrappedNodeExpr, ctx: EmitterVisitorContext): never { + throw new Error('Abstract emitter cannot visit WrappedNodeExpr.'); + } visitReadVarExpr(ast: o.ReadVarExpr, ctx: EmitterVisitorContext): any { let varName = ast.name !; if (ast.builtin != null) { diff --git a/packages/compiler/src/output/abstract_js_emitter.ts b/packages/compiler/src/output/abstract_js_emitter.ts index 8dfa19bc3d..943fab81e1 100644 --- a/packages/compiler/src/output/abstract_js_emitter.ts +++ b/packages/compiler/src/output/abstract_js_emitter.ts @@ -70,6 +70,9 @@ export abstract class AbstractJsEmitterVisitor extends AbstractEmitterVisitor { ctx.println(stmt, `};`); } + visitWrappedNodeExpr(ast: o.WrappedNodeExpr, ctx: EmitterVisitorContext): never { + throw new Error('Cannot emit a WrappedNodeExpr in Javascript.'); + } visitReadVarExpr(ast: o.ReadVarExpr, ctx: EmitterVisitorContext): string|null { if (ast.builtin === o.BuiltinVar.This) { ctx.print(ast, 'self'); diff --git a/packages/compiler/src/output/output_ast.ts b/packages/compiler/src/output/output_ast.ts index 69e0ac2f5d..69d05c67fe 100644 --- a/packages/compiler/src/output/output_ast.ts +++ b/packages/compiler/src/output/output_ast.ts @@ -279,6 +279,21 @@ export class ReadVarExpr extends Expression { } } +export class WrappedNodeExpr extends Expression { + constructor(public node: T, type?: Type|null, sourceSpan?: ParseSourceSpan|null) { + super(type, sourceSpan); + } + + isEquivalent(e: Expression): boolean { + return e instanceof WrappedNodeExpr && this.node === e.node; + } + + isConstant() { return false; } + + visitExpression(visitor: ExpressionVisitor, context: any): any { + return visitor.visitWrappedNodeExpr(this, context); + } +} export class WriteVarExpr extends Expression { public value: Expression; @@ -722,6 +737,7 @@ export interface ExpressionVisitor { visitLiteralArrayExpr(ast: LiteralArrayExpr, context: any): any; visitLiteralMapExpr(ast: LiteralMapExpr, context: any): any; visitCommaExpr(ast: CommaExpr, context: any): any; + visitWrappedNodeExpr(ast: WrappedNodeExpr, context: any): any; } export const THIS_EXPR = new ReadVarExpr(BuiltinVar.This, null, null); @@ -973,6 +989,10 @@ export class AstTransformer implements StatementVisitor, ExpressionVisitor { visitReadVarExpr(ast: ReadVarExpr, context: any): any { return this.transformExpr(ast, context); } + visitWrappedNodeExpr(ast: WrappedNodeExpr, context: any): any { + return this.transformExpr(ast, context); + } + visitWriteVarExpr(expr: WriteVarExpr, context: any): any { return this.transformExpr( new WriteVarExpr( @@ -1199,6 +1219,7 @@ export class RecursiveAstVisitor implements StatementVisitor, ExpressionVisitor } visitArrayType(type: ArrayType, context: any): any { return this.visitType(type, context); } visitMapType(type: MapType, context: any): any { return this.visitType(type, context); } + visitWrappedNodeExpr(ast: WrappedNodeExpr, context: any): any { return ast; } visitReadVarExpr(ast: ReadVarExpr, context: any): any { return this.visitExpression(ast, context); } diff --git a/packages/compiler/src/output/output_interpreter.ts b/packages/compiler/src/output/output_interpreter.ts index f7fbec6412..fdf3d61ac1 100644 --- a/packages/compiler/src/output/output_interpreter.ts +++ b/packages/compiler/src/output/output_interpreter.ts @@ -114,6 +114,9 @@ class StatementInterpreter implements o.StatementVisitor, o.ExpressionVisitor { } throw new Error(`Not declared variable ${expr.name}`); } + visitWrappedNodeExpr(ast: o.WrappedNodeExpr, ctx: _ExecutionContext): never { + throw new Error('Cannot interpret a WrappedNodeExpr.'); + } visitReadVarExpr(ast: o.ReadVarExpr, ctx: _ExecutionContext): any { let varName = ast.name !; if (ast.builtin != null) { diff --git a/packages/compiler/src/output/ts_emitter.ts b/packages/compiler/src/output/ts_emitter.ts index ef96f62001..eff352943e 100644 --- a/packages/compiler/src/output/ts_emitter.ts +++ b/packages/compiler/src/output/ts_emitter.ts @@ -169,6 +169,10 @@ class _TsEmitterVisitor extends AbstractEmitterVisitor implements o.TypeVisitor return null; } + visitWrappedNodeExpr(ast: o.WrappedNodeExpr, ctx: EmitterVisitorContext): never { + throw new Error('Cannot visit a WrappedNodeExpr when outputting Typescript.'); + } + visitCastExpr(ast: o.CastExpr, ctx: EmitterVisitorContext): any { ctx.print(ast, `(<`); ast.type !.visitType(this, ctx); diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index c18542fbe2..b0a6c9af22 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -71,6 +71,12 @@ export class Identifiers { static projection: o.ExternalReference = {name: 'ɵP', moduleName: CORE}; static projectionDef: o.ExternalReference = {name: 'ɵpD', moduleName: CORE}; + static refreshComponent: o.ExternalReference = {name: 'ɵr', moduleName: CORE}; + + static directiveLifeCycle: o.ExternalReference = {name: 'ɵl', moduleName: CORE}; + + static inject: o.ExternalReference = {name: 'inject', moduleName: CORE}; + static injectAttribute: o.ExternalReference = {name: 'ɵinjectAttribute', moduleName: CORE}; static injectElementRef: o.ExternalReference = {name: 'ɵinjectElementRef', moduleName: CORE}; diff --git a/tools/testing/BUILD.bazel b/tools/testing/BUILD.bazel index 608097e237..11605471fc 100644 --- a/tools/testing/BUILD.bazel +++ b/tools/testing/BUILD.bazel @@ -23,3 +23,10 @@ ts_library( "//packages/platform-server/testing", ], ) + +ts_library( + name = "node_no_angular", + testonly = 1, + srcs = ["init_node_no_angular_spec.ts"], + deps = ["//packages:types"], +) diff --git a/tools/testing/README.md b/tools/testing/README.md new file mode 100644 index 0000000000..63db6735ab --- /dev/null +++ b/tools/testing/README.md @@ -0,0 +1,9 @@ +The spec helper files here set up the global testing environment prior to the execution of specs. + +There are 3 options: + +* `init_node_spec` - configures a node environment to test Angular applications with +platform-server. +* `init_node_no_angular_spec` - configures a node environment for testing without setting up +Angular's testbed (no dependency on Angular packages is incurred). +* `init_browser_spec` - configures a browser environment to test Angular applications. \ No newline at end of file diff --git a/tools/testing/init_node_no_angular_spec.ts b/tools/testing/init_node_no_angular_spec.ts new file mode 100644 index 0000000000..f2df6682df --- /dev/null +++ b/tools/testing/init_node_no_angular_spec.ts @@ -0,0 +1,26 @@ +/** + * @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 + */ + +// This hack is needed to get jasmine, node and zone working inside bazel. +// 1) we load `jasmine-core` which contains the ENV: it, describe etc... +const jasmineCore: any = require('jasmine-core'); +// 2) We create an instance of `jasmine` ENV. +const patchedJasmine = jasmineCore.boot(jasmineCore); +// 3) Save the `jasmine` into global so that `zone.js/dist/jasmine-patch.js` can get a hold of it to +// patch it. +(global as any)['jasmine'] = patchedJasmine; +// 4) Change the `jasmine-core` to make sure that all subsequent jasmine's have the same ENV, +// otherwise the patch will not work. +// This is needed since Bazel creates a new instance of jasmine and it's ENV and we want to make +// sure it gets the same one. +jasmineCore.boot = function() { + return patchedJasmine; +}; + +(global as any).isNode = true; +(global as any).isBrowser = false;