From 7dd0db6d4f3f54e1566d54cbf61142f6289d825b Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Wed, 16 Sep 2020 11:19:56 +0100 Subject: [PATCH] refactor(compiler-cli): implement `BabelAstFactory` and `AstHost`s (#38866) This commit adds the `AstHost` interface, along with implementations for both Babel and TS. It also implements the Babel vesion of the `AstFactory` interface, along with a linker specific implementation of the `ImportGenerator` interface. These classes will be used by the new "ng-linker" to transform prelinked library code using a Babel plugin. PR Close #38866 --- .github/angular-robot.yml | 1 + packages/compiler-cli/linker/BUILD.bazel | 18 + packages/compiler-cli/linker/index.ts | 7 + .../compiler-cli/linker/src/ast/ast_host.ts | 95 +++++ .../linker/src/ast/babel/babel_ast_factory.ts | 165 ++++++++ .../linker/src/ast/babel/babel_ast_host.ts | 142 +++++++ .../src/ast/typescript/typescript_ast_host.ts | 147 +++++++ packages/compiler-cli/linker/src/ast/utils.ts | 18 + .../linker/src/fatal_linker_error.ts | 31 ++ .../linker/src/linker_import_generator.ts | 37 ++ packages/compiler-cli/linker/test/BUILD.bazel | 33 ++ .../test/ast/babel/babel_ast_factory_spec.ts | 382 ++++++++++++++++++ .../test/ast/babel/babel_ast_host_spec.ts | 305 ++++++++++++++ .../typescript/typescript_ast_host_spec.ts | 289 +++++++++++++ .../linker/test/fatal_linker_error_spec.ts | 29 ++ .../test/linker_import_generator_spec.ts | 41 ++ packages/compiler-cli/package.json | 4 +- .../src/ngtsc/translator/index.ts | 2 +- .../ngtsc/translator/src/api/ast_factory.ts | 2 +- .../translator/src/typescript_ast_factory.ts | 2 +- 20 files changed, 1746 insertions(+), 4 deletions(-) create mode 100644 packages/compiler-cli/linker/BUILD.bazel create mode 100644 packages/compiler-cli/linker/index.ts create mode 100644 packages/compiler-cli/linker/src/ast/ast_host.ts create mode 100644 packages/compiler-cli/linker/src/ast/babel/babel_ast_factory.ts create mode 100644 packages/compiler-cli/linker/src/ast/babel/babel_ast_host.ts create mode 100644 packages/compiler-cli/linker/src/ast/typescript/typescript_ast_host.ts create mode 100644 packages/compiler-cli/linker/src/ast/utils.ts create mode 100644 packages/compiler-cli/linker/src/fatal_linker_error.ts create mode 100644 packages/compiler-cli/linker/src/linker_import_generator.ts create mode 100644 packages/compiler-cli/linker/test/BUILD.bazel create mode 100644 packages/compiler-cli/linker/test/ast/babel/babel_ast_factory_spec.ts create mode 100644 packages/compiler-cli/linker/test/ast/babel/babel_ast_host_spec.ts create mode 100644 packages/compiler-cli/linker/test/ast/typescript/typescript_ast_host_spec.ts create mode 100644 packages/compiler-cli/linker/test/fatal_linker_error_spec.ts create mode 100644 packages/compiler-cli/linker/test/linker_import_generator_spec.ts diff --git a/.github/angular-robot.yml b/.github/angular-robot.yml index 803740acf0..119787b86c 100644 --- a/.github/angular-robot.yml +++ b/.github/angular-robot.yml @@ -47,6 +47,7 @@ merge: - "packages/bazel/src/ng_package/**" - "packages/bazel/src/protractor/**" - "packages/bazel/src/schematics/**" + - "packages/compiler-cli/linker/**" - "packages/compiler-cli/ngcc/**" - "packages/compiler-cli/src/ngtsc/sourcemaps/**" - "packages/docs/**" diff --git a/packages/compiler-cli/linker/BUILD.bazel b/packages/compiler-cli/linker/BUILD.bazel new file mode 100644 index 0000000000..9324fa61a9 --- /dev/null +++ b/packages/compiler-cli/linker/BUILD.bazel @@ -0,0 +1,18 @@ +load("//tools:defaults.bzl", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "linker", + srcs = ["index.ts"] + glob([ + "src/**/*.ts", + ]), + deps = [ + "//packages/compiler-cli/src/ngtsc/translator", + "@npm//@babel/core", + "@npm//@babel/types", + "@npm//@types/babel__core", + "@npm//@types/babel__traverse", + "@npm//typescript", + ], +) diff --git a/packages/compiler-cli/linker/index.ts b/packages/compiler-cli/linker/index.ts new file mode 100644 index 0000000000..823e9bf40a --- /dev/null +++ b/packages/compiler-cli/linker/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright Google LLC 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 + */ diff --git a/packages/compiler-cli/linker/src/ast/ast_host.ts b/packages/compiler-cli/linker/src/ast/ast_host.ts new file mode 100644 index 0000000000..70f55bb5d7 --- /dev/null +++ b/packages/compiler-cli/linker/src/ast/ast_host.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +/** + * An abstraction for getting information from an AST while being agnostic to the underlying AST + * implementation. + */ +export interface AstHost { + /** + * Get the name of the symbol represented by the given expression node, or `null` if it is not a + * symbol. + */ + getSymbolName(node: TExpression): string|null; + + /** + * Return `true` if the given expression is a string literal, or false otherwise. + */ + isStringLiteral(node: TExpression): boolean; + /** + * Parse the string value from the given expression, or throw if it is not a string literal. + */ + parseStringLiteral(str: TExpression): string; + + /** + * Return `true` if the given expression is a numeric literal, or false otherwise. + */ + isNumericLiteral(node: TExpression): boolean; + /** + * Parse the numeric value from the given expression, or throw if it is not a numeric literal. + */ + parseNumericLiteral(num: TExpression): number; + + /** + * Return `true` if the given expression is a boolean literal, or false otherwise. + */ + isBooleanLiteral(node: TExpression): boolean; + /** + * Parse the boolean value from the given expression, or throw if it is not a boolean literal. + */ + parseBooleanLiteral(bool: TExpression): boolean; + + /** + * Return `true` if the given expression is an array literal, or false otherwise. + */ + isArrayLiteral(node: TExpression): boolean; + /** + * Parse an array of expressions from the given expression, or throw if it is not an array + * literal. + */ + parseArrayLiteral(array: TExpression): TExpression[]; + + /** + * Return `true` if the given expression is an object literal, or false otherwise. + */ + isObjectLiteral(node: TExpression): boolean; + /** + * Parse the given expression into a map of object property names to property expressions, or + * throw if it is not an object literal. + */ + parseObjectLiteral(obj: TExpression): Map; + + /** + * Return `true` if the given expression is a function, or false otherwise. + */ + isFunctionExpression(node: TExpression): boolean; + /** + * Compute the "value" of a function expression by parsing its body for a single `return` + * statement, extracting the returned expression, or throw if it is not possible. + */ + parseReturnValue(fn: TExpression): TExpression; + + /** + * Compute the location range of the expression in the source file, to be used for source-mapping. + */ + getRange(node: TExpression): Range; +} + +/** + * The location of the start and end of an expression in the original source file. + */ +export interface Range { + /** 0-based character position of the range start in the source file text. */ + startPos: number; + /** 0-based line index of the range start in the source file text. */ + startLine: number; + /** 0-based column position of the range start in the source file text. */ + startCol: number; + /** 0-based character position of the range end in the source file text. */ + endPos: number; +} diff --git a/packages/compiler-cli/linker/src/ast/babel/babel_ast_factory.ts b/packages/compiler-cli/linker/src/ast/babel/babel_ast_factory.ts new file mode 100644 index 0000000000..60b3459fac --- /dev/null +++ b/packages/compiler-cli/linker/src/ast/babel/babel_ast_factory.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright Google LLC 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 t from '@babel/types'; + +import {AstFactory, BinaryOperator, LeadingComment, ObjectLiteralProperty, SourceMapRange, TemplateLiteral, VariableDeclarationType} from '../../../../src/ngtsc/translator'; +import {assert} from '../utils'; + +export class BabelAstFactory implements AstFactory { + attachComments(statement: t.Statement, leadingComments: LeadingComment[]|undefined): t.Statement { + if (leadingComments === undefined) { + return statement; + } + // We must process the comments in reverse because `t.addComment()` will add new ones in front. + for (let i = leadingComments.length - 1; i >= 0; i--) { + const comment = leadingComments[i]; + t.addComment(statement, 'leading', comment.toString(), !comment.multiline); + } + return statement; + } + + createArrayLiteral = t.arrayExpression; + + createAssignment(target: t.Expression, value: t.Expression): t.Expression { + assert(target, isLExpression, 'must be a left hand side expression'); + return t.assignmentExpression('=', target, value); + } + + createBinaryExpression( + leftOperand: t.Expression, operator: BinaryOperator, + rightOperand: t.Expression): t.Expression { + switch (operator) { + case '&&': + case '||': + return t.logicalExpression(operator, leftOperand, rightOperand); + default: + return t.binaryExpression(operator, leftOperand, rightOperand); + } + } + + createBlock = t.blockStatement; + + createCallExpression(callee: t.Expression, args: t.Expression[], pure: boolean): t.Expression { + const call = t.callExpression(callee, args); + if (pure) { + t.addComment(call, 'leading', ' @__PURE__ ', /* line */ false); + } + return call; + } + + createConditional = t.conditionalExpression; + + createElementAccess(expression: t.Expression, element: t.Expression): t.Expression { + return t.memberExpression(expression, element, /* computed */ true); + } + + createExpressionStatement = t.expressionStatement; + + createFunctionDeclaration(functionName: string, parameters: string[], body: t.Statement): + t.Statement { + assert(body, t.isBlockStatement, 'a block'); + return t.functionDeclaration( + t.identifier(functionName), parameters.map(param => t.identifier(param)), body); + } + + createFunctionExpression(functionName: string|null, parameters: string[], body: t.Statement): + t.Expression { + assert(body, t.isBlockStatement, 'a block'); + const name = functionName !== null ? t.identifier(functionName) : null; + return t.functionExpression(name, parameters.map(param => t.identifier(param)), body); + } + + createIdentifier = t.identifier; + + createIfStatement = t.ifStatement; + + createLiteral(value: string|number|boolean|null|undefined): t.Expression { + if (typeof value === 'string') { + return t.stringLiteral(value); + } else if (typeof value === 'number') { + return t.numericLiteral(value); + } else if (typeof value === 'boolean') { + return t.booleanLiteral(value); + } else if (value === undefined) { + return t.identifier('undefined'); + } else if (value === null) { + return t.nullLiteral(); + } else { + throw new Error(`Invalid literal: ${value} (${typeof value})`); + } + } + + createNewExpression = t.newExpression; + + createObjectLiteral(properties: ObjectLiteralProperty[]): t.Expression { + return t.objectExpression(properties.map(prop => { + const key = + prop.quoted ? t.stringLiteral(prop.propertyName) : t.identifier(prop.propertyName); + return t.objectProperty(key, prop.value); + })); + } + + createParenthesizedExpression = t.parenthesizedExpression; + + createPropertyAccess(expression: t.Expression, propertyName: string): t.Expression { + return t.memberExpression(expression, t.identifier(propertyName), /* computed */ false); + } + + createReturnStatement = t.returnStatement; + + createTaggedTemplate(tag: t.Expression, template: TemplateLiteral): t.Expression { + const elements = template.elements.map( + (element, i) => this.setSourceMapRange( + t.templateElement(element, i === template.elements.length - 1), element.range)); + return t.taggedTemplateExpression(tag, t.templateLiteral(elements, template.expressions)); + } + + createThrowStatement = t.throwStatement; + + createTypeOfExpression(expression: t.Expression): t.Expression { + return t.unaryExpression('typeof', expression); + } + + createUnaryExpression = t.unaryExpression; + + createVariableDeclaration( + variableName: string, initializer: t.Expression|null, + type: VariableDeclarationType): t.Statement { + return t.variableDeclaration( + type, [t.variableDeclarator(t.identifier(variableName), initializer)]); + } + + setSourceMapRange( + node: T, sourceMapRange: SourceMapRange|null): T { + if (sourceMapRange === null) { + return node; + } + // Note that the linker only works on a single file at a time, so there is no need to track the + // filename. Babel will just use the current filename in the source-map. + node.loc = { + start: { + line: sourceMapRange.start.line + 1, // lines are 1-based in Babel. + column: sourceMapRange.start.column, + }, + end: { + line: sourceMapRange.end.line + 1, // lines are 1-based in Babel. + column: sourceMapRange.end.column, + }, + }; + node.start = sourceMapRange.start.offset; + node.end = sourceMapRange.end.offset; + + return node; + } +} + +function isLExpression(expr: t.Expression): expr is Extract { + // Some LVal types are not expressions, which prevents us from using `t.isLVal()` + // directly with `assert()`. + return t.isLVal(expr); +} diff --git a/packages/compiler-cli/linker/src/ast/babel/babel_ast_host.ts b/packages/compiler-cli/linker/src/ast/babel/babel_ast_host.ts new file mode 100644 index 0000000000..a31ba6a536 --- /dev/null +++ b/packages/compiler-cli/linker/src/ast/babel/babel_ast_host.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright Google LLC 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 t from '@babel/types'; + +import {FatalLinkerError} from '../../fatal_linker_error'; +import {AstHost, Range} from '../ast_host'; +import {assert} from '../utils'; + +/** + * This implementation of `AstHost` is able to get information from Babel AST nodes. + */ +export class BabelAstHost implements AstHost { + getSymbolName(node: t.Expression): string|null { + if (t.isIdentifier(node)) { + return node.name; + } else if (t.isMemberExpression(node) && t.isIdentifier(node.property)) { + return node.property.name; + } else { + return null; + } + } + + isStringLiteral = t.isStringLiteral; + + parseStringLiteral(str: t.Expression): string { + assert(str, t.isStringLiteral, 'a string literal'); + return str.value; + } + + isNumericLiteral = t.isNumericLiteral; + + parseNumericLiteral(num: t.Expression): number { + assert(num, t.isNumericLiteral, 'a numeric literal'); + return num.value; + } + + isBooleanLiteral = t.isBooleanLiteral; + + parseBooleanLiteral(bool: t.Expression): boolean { + assert(bool, t.isBooleanLiteral, 'a boolean literal'); + return bool.value; + } + + isArrayLiteral = t.isArrayExpression; + + parseArrayLiteral(array: t.Expression): t.Expression[] { + assert(array, t.isArrayExpression, 'an array literal'); + return array.elements.map(element => { + assert(element, isNotEmptyElement, 'element in array not to be empty'); + assert(element, isNotSpreadElement, 'element in array not to use spread syntax'); + return element; + }); + } + + isObjectLiteral = t.isObjectExpression; + + parseObjectLiteral(obj: t.Expression): Map { + assert(obj, t.isObjectExpression, 'an object literal'); + + const result = new Map(); + for (const property of obj.properties) { + assert(property, t.isObjectProperty, 'a property assignment'); + assert(property.value, t.isExpression, 'an expression'); + assert(property.key, isPropertyName, 'a property name'); + const key = t.isIdentifier(property.key) ? property.key.name : property.key.value; + result.set(key, property.value); + } + return result; + } + + isFunctionExpression(node: t.Expression): node is Extract { + return t.isFunction(node); + } + + parseReturnValue(fn: t.Expression): t.Expression { + assert(fn, this.isFunctionExpression, 'a function'); + if (!t.isBlockStatement(fn.body)) { + // it is a simple array function expression: `(...) => expr` + return fn.body; + } + + // it is a function (arrow or normal) with a body. E.g.: + // * `(...) => { stmt; ... }` + // * `function(...) { stmt; ... }` + + if (fn.body.body.length !== 1) { + throw new FatalLinkerError( + fn.body, 'Unsupported syntax, expected a function body with a single return statement.'); + } + const stmt = fn.body.body[0]; + assert(stmt, t.isReturnStatement, 'a function body with a single return statement'); + if (stmt.argument === null) { + throw new FatalLinkerError(stmt, 'Unsupported syntax, expected function to return a value.'); + } + + return stmt.argument; + } + + getRange(node: t.Expression): Range { + if (node.loc == null || node.start === null || node.end === null) { + throw new FatalLinkerError( + node, 'Unable to read range for node - it is missing location information.'); + } + return { + startLine: node.loc.start.line - 1, // Babel lines are 1-based + startCol: node.loc.start.column, + startPos: node.start, + endPos: node.end, + }; + } +} + +/** + * Return true if the expression does not represent an empty element in an array literal. + * For example in `[,foo]` the first element is "empty". + */ +function isNotEmptyElement(e: t.Expression|t.SpreadElement|null): e is t.Expression| + t.SpreadElement { + return e !== null; +} + +/** + * Return true if the expression is not a spread element of an array literal. + * For example in `[x, ...rest]` the `...rest` expression is a spread element. + */ +function isNotSpreadElement(e: t.Expression|t.SpreadElement): e is t.Expression { + return !t.isSpreadElement(e); +} + + +/** + * Return true if the expression can be considered a text based property name. + */ +function isPropertyName(e: t.Expression): e is t.Identifier|t.StringLiteral|t.NumericLiteral { + return t.isIdentifier(e) || t.isStringLiteral(e) || t.isNumericLiteral(e); +} diff --git a/packages/compiler-cli/linker/src/ast/typescript/typescript_ast_host.ts b/packages/compiler-cli/linker/src/ast/typescript/typescript_ast_host.ts new file mode 100644 index 0000000000..582f5823a0 --- /dev/null +++ b/packages/compiler-cli/linker/src/ast/typescript/typescript_ast_host.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright Google LLC 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 {FatalLinkerError} from '../../fatal_linker_error'; +import {AstHost, Range} from '../ast_host'; +import {assert} from '../utils'; + + +/** + * This implementation of `AstHost` is able to get information from TypeScript AST nodes. + * + * This host is not actually used at runtime in the current code. + * + * It is implemented here to ensure that the `AstHost` abstraction is not unfairly skewed towards + * the Babel implementation. It could also provide a basis for a 3rd TypeScript compiler plugin to + * do linking in the future. + */ +export class TypeScriptAstHost implements AstHost { + getSymbolName(node: ts.Expression): string|null { + if (ts.isIdentifier(node)) { + return node.text; + } else if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name)) { + return node.name.text; + } else { + return null; + } + } + + isStringLiteral = ts.isStringLiteral; + + parseStringLiteral(str: ts.Expression): string { + assert(str, this.isStringLiteral, 'a string literal'); + return str.text; + } + + isNumericLiteral = ts.isNumericLiteral; + + parseNumericLiteral(num: ts.Expression): number { + assert(num, this.isNumericLiteral, 'a numeric literal'); + return parseInt(num.text); + } + + isBooleanLiteral(node: ts.Expression): node is ts.FalseLiteral|ts.TrueLiteral { + return node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword; + } + + parseBooleanLiteral(bool: ts.Expression): boolean { + assert(bool, this.isBooleanLiteral, 'a boolean literal'); + return bool.kind === ts.SyntaxKind.TrueKeyword; + } + + isArrayLiteral = ts.isArrayLiteralExpression; + + parseArrayLiteral(array: ts.Expression): ts.Expression[] { + assert(array, this.isArrayLiteral, 'an array literal'); + return array.elements.map(element => { + assert(element, isNotEmptyElement, 'element in array not to be empty'); + assert(element, isNotSpreadElement, 'element in array not to use spread syntax'); + return element; + }); + } + + isObjectLiteral = ts.isObjectLiteralExpression; + + parseObjectLiteral(obj: ts.Expression): Map { + assert(obj, this.isObjectLiteral, 'an object literal'); + + const result = new Map(); + for (const property of obj.properties) { + assert(property, ts.isPropertyAssignment, 'a property assignment'); + assert(property.name, isPropertyName, 'a property name'); + result.set(property.name.text, property.initializer); + } + return result; + } + + isFunctionExpression(node: ts.Expression): node is ts.FunctionExpression|ts.ArrowFunction { + return ts.isFunctionExpression(node) || ts.isArrowFunction(node); + } + + parseReturnValue(fn: ts.Expression): ts.Expression { + assert(fn, this.isFunctionExpression, 'a function'); + if (!ts.isBlock(fn.body)) { + // it is a simple array function expression: `(...) => expr` + return fn.body; + } + + // it is a function (arrow or normal) with a body. E.g.: + // * `(...) => { stmt; ... }` + // * `function(...) { stmt; ... }` + + if (fn.body.statements.length !== 1) { + throw new FatalLinkerError( + fn.body, 'Unsupported syntax, expected a function body with a single return statement.'); + } + const stmt = fn.body.statements[0]; + assert(stmt, ts.isReturnStatement, 'a function body with a single return statement'); + if (stmt.expression === undefined) { + throw new FatalLinkerError(stmt, 'Unsupported syntax, expected function to return a value.'); + } + + return stmt.expression; + } + + getRange(node: ts.Expression): Range { + const file = node.getSourceFile(); + if (file === undefined) { + throw new FatalLinkerError( + node, 'Unable to read range for node - it is missing parent information.'); + } + const startPos = node.getStart(); + const endPos = node.getEnd(); + const {line: startLine, character: startCol} = ts.getLineAndCharacterOfPosition(file, startPos); + return {startLine, startCol, startPos, endPos}; + } +} + +/** + * Return true if the expression does not represent an empty element in an array literal. + * For example in `[,foo]` the first element is "empty". + */ +function isNotEmptyElement(e: ts.Expression|ts.SpreadElement| + ts.OmittedExpression): e is ts.Expression|ts.SpreadElement { + return !ts.isOmittedExpression(e); +} + +/** + * Return true if the expression is not a spread element of an array literal. + * For example in `[x, ...rest]` the `...rest` expression is a spread element. + */ +function isNotSpreadElement(e: ts.Expression|ts.SpreadElement): e is ts.Expression { + return !ts.isSpreadElement(e); +} + +/** + * Return true if the expression can be considered a text based property name. + */ +function isPropertyName(e: ts.PropertyName): e is ts.Identifier|ts.StringLiteral|ts.NumericLiteral { + return ts.isIdentifier(e) || ts.isStringLiteral(e) || ts.isNumericLiteral(e); +} diff --git a/packages/compiler-cli/linker/src/ast/utils.ts b/packages/compiler-cli/linker/src/ast/utils.ts new file mode 100644 index 0000000000..2aa9437361 --- /dev/null +++ b/packages/compiler-cli/linker/src/ast/utils.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright Google LLC 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 {FatalLinkerError} from '../fatal_linker_error'; + +/** + * Assert that the given `node` is of the type guarded by the `predicate` function. + */ +export function assert( + node: T, predicate: (node: T) => node is K, expected: string): asserts node is K { + if (!predicate(node)) { + throw new FatalLinkerError(node, `Unsupported syntax, expected ${expected}.`); + } +} diff --git a/packages/compiler-cli/linker/src/fatal_linker_error.ts b/packages/compiler-cli/linker/src/fatal_linker_error.ts new file mode 100644 index 0000000000..df494a8929 --- /dev/null +++ b/packages/compiler-cli/linker/src/fatal_linker_error.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +/** + * An unrecoverable error during linking. + */ +export class FatalLinkerError extends Error { + private readonly type = 'FatalLinkerError'; + + /** + * Create a new FatalLinkerError. + * + * @param node The AST node where the error occurred. + * @param message A description of the error. + */ + constructor(public node: unknown, message: string) { + super(message); + } +} + +/** + * Whether the given object `e` is a FatalLinkerError. + */ +export function isFatalLinkerError(e: any): e is FatalLinkerError { + return e && e.type === 'FatalLinkerError'; +} diff --git a/packages/compiler-cli/linker/src/linker_import_generator.ts b/packages/compiler-cli/linker/src/linker_import_generator.ts new file mode 100644 index 0000000000..00d3bfbc33 --- /dev/null +++ b/packages/compiler-cli/linker/src/linker_import_generator.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC 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 {ImportGenerator, NamedImport} from '../../src/ngtsc/translator'; + +/** + * A class that is used to generate imports when translating from Angular Output AST to an AST to + * render, such as Babel. + * + * Note that, in the linker, there can only be imports from `@angular/core` and that these imports + * must be achieved by property access on an `ng` namespace identifer, which is passed in via the + * constructor. + */ +export class LinkerImportGenerator implements ImportGenerator { + constructor(private ngImport: TExpression) {} + + generateNamespaceImport(moduleName: string): TExpression { + this.assertModuleName(moduleName); + return this.ngImport; + } + + generateNamedImport(moduleName: string, originalSymbol: string): NamedImport { + this.assertModuleName(moduleName); + return {moduleImport: this.ngImport, symbol: originalSymbol}; + } + + private assertModuleName(moduleName: string): void { + if (moduleName !== '@angular/core') { + throw new Error(`Unable to import from anything other than '@angular/core'`); + } + } +} diff --git a/packages/compiler-cli/linker/test/BUILD.bazel b/packages/compiler-cli/linker/test/BUILD.bazel new file mode 100644 index 0000000000..83901dd4c1 --- /dev/null +++ b/packages/compiler-cli/linker/test/BUILD.bazel @@ -0,0 +1,33 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob([ + "**/*.ts", + ]), + deps = [ + "//packages:types", + "//packages/compiler", + "//packages/compiler-cli/linker", + "@npm//@babel/core", + "@npm//@babel/generator", + "@npm//@babel/parser", + "@npm//@babel/template", + "@npm//@babel/types", + "@npm//@types/babel__core", + "@npm//@types/babel__generator", + "@npm//@types/babel__template", + "@npm//typescript", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = ["//tools/testing:node_no_angular_es5"], + deps = [ + ":test_lib", + ], +) diff --git a/packages/compiler-cli/linker/test/ast/babel/babel_ast_factory_spec.ts b/packages/compiler-cli/linker/test/ast/babel/babel_ast_factory_spec.ts new file mode 100644 index 0000000000..106e2e70d2 --- /dev/null +++ b/packages/compiler-cli/linker/test/ast/babel/babel_ast_factory_spec.ts @@ -0,0 +1,382 @@ +/** + * @license + * Copyright Google LLC 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 {leadingComment} from '@angular/compiler'; +import generate from '@babel/generator'; +import {expression, statement} from '@babel/template'; +import * as t from '@babel/types'; + +import {BabelAstFactory} from '../../../src/ast/babel/babel_ast_factory'; + +describe('BabelAstFactory', () => { + let factory: BabelAstFactory; + beforeEach(() => factory = new BabelAstFactory()); + + describe('attachComments()', () => { + it('should add the comments to the given statement', () => { + const stmt = statement.ast`x = 10;`; + factory.attachComments( + stmt, [leadingComment('comment 1', true), leadingComment('comment 2', false)]); + + expect(generate(stmt).code).toEqual([ + '/* comment 1 */', + '//comment 2', + 'x = 10;', + ].join('\n')); + }); + }); + + describe('createArrayLiteral()', () => { + it('should create an array node containing the provided expressions', () => { + const expr1 = expression.ast`42`; + const expr2 = expression.ast`"moo"`; + + const array = factory.createArrayLiteral([expr1, expr2]); + expect(generate(array).code).toEqual('[42, "moo"]'); + }); + }); + + describe('createAssignment()', () => { + it('should create an assignment node using the target and value expressions', () => { + const target = expression.ast`x`; + const value = expression.ast`42`; + const assignment = factory.createAssignment(target, value); + expect(generate(assignment).code).toEqual('x = 42'); + }); + }); + + describe('createBinaryExpression()', () => { + it('should create a binary operation node using the left and right expressions', () => { + const left = expression.ast`17`; + const right = expression.ast`42`; + const expr = factory.createBinaryExpression(left, '+', right); + expect(generate(expr).code).toEqual('17 + 42'); + }); + + it('should create a binary operation node for logical operators', () => { + const left = expression.ast`17`; + const right = expression.ast`42`; + const expr = factory.createBinaryExpression(left, '&&', right); + expect(t.isLogicalExpression(expr)).toBe(true); + expect(generate(expr).code).toEqual('17 && 42'); + }); + }); + + describe('createBlock()', () => { + it('should create a block statement containing the given statements', () => { + const stmt1 = statement.ast`x = 10`; + const stmt2 = statement.ast`y = 20`; + const block = factory.createBlock([stmt1, stmt2]); + expect(generate(block).code).toEqual([ + '{', + ' x = 10;', + ' y = 20;', + '}', + ].join('\n')); + }); + }); + + describe('createCallExpression()', () => { + it('should create a call on the `callee` with the given `args`', () => { + const callee = expression.ast`foo`; + const arg1 = expression.ast`42`; + const arg2 = expression.ast`"moo"`; + const call = factory.createCallExpression(callee, [arg1, arg2], false); + expect(generate(call).code).toEqual('foo(42, "moo")'); + }); + + it('should create a call marked with a PURE comment if `pure` is true', () => { + const callee = expression.ast`foo`; + const arg1 = expression.ast`42`; + const arg2 = expression.ast`"moo"`; + const call = factory.createCallExpression(callee, [arg1, arg2], true); + expect(generate(call).code).toEqual(['/* @__PURE__ */', 'foo(42, "moo")'].join('\n')); + }); + }); + + describe('createConditional()', () => { + it('should create a condition expression', () => { + const test = expression.ast`!test`; + const thenExpr = expression.ast`42`; + const elseExpr = expression.ast`"moo"`; + const conditional = factory.createConditional(test, thenExpr, elseExpr); + expect(generate(conditional).code).toEqual('!test ? 42 : "moo"'); + }); + }); + + describe('createElementAccess()', () => { + it('should create an expression accessing the element of an array/object', () => { + const expr = expression.ast`obj`; + const element = expression.ast`"moo"`; + const access = factory.createElementAccess(expr, element); + expect(generate(access).code).toEqual('obj["moo"]'); + }); + }); + + describe('createExpressionStatement()', () => { + it('should create a statement node from the given expression', () => { + const expr = expression.ast`x = 10`; + const stmt = factory.createExpressionStatement(expr); + expect(t.isStatement(stmt)).toBe(true); + expect(generate(stmt).code).toEqual('x = 10;'); + }); + }); + + describe('createFunctionDeclaration()', () => { + it('should create a function declaration node with the given name, parameters and body statements', + () => { + const stmts = statement.ast`{x = 10; y = 20;}`; + const fn = factory.createFunctionDeclaration('foo', ['arg1', 'arg2'], stmts); + expect(generate(fn).code).toEqual([ + 'function foo(arg1, arg2) {', + ' x = 10;', + ' y = 20;', + '}', + ].join('\n')); + }); + }); + + describe('createFunctionExpression()', () => { + it('should create a function expression node with the given name, parameters and body statements', + () => { + const stmts = statement.ast`{x = 10; y = 20;}`; + const fn = factory.createFunctionExpression('foo', ['arg1', 'arg2'], stmts); + expect(t.isStatement(fn)).toBe(false); + expect(generate(fn).code).toEqual([ + 'function foo(arg1, arg2) {', + ' x = 10;', + ' y = 20;', + '}', + ].join('\n')); + }); + + it('should create an anonymous function expression node if the name is null', () => { + const stmts = statement.ast`{x = 10; y = 20;}`; + const fn = factory.createFunctionExpression(null, ['arg1', 'arg2'], stmts); + expect(generate(fn).code).toEqual([ + 'function (arg1, arg2) {', + ' x = 10;', + ' y = 20;', + '}', + ].join('\n')); + }); + }); + + describe('createIdentifier()', () => { + it('should create an identifier with the given name', () => { + const id = factory.createIdentifier('someId') as t.Identifier; + expect(t.isIdentifier(id)).toBe(true); + expect(id.name).toEqual('someId'); + }); + }); + + describe('createIfStatement()', () => { + it('should create an if-else statement', () => { + const test = expression.ast`!test`; + const thenStmt = statement.ast`x = 10;`; + const elseStmt = statement.ast`x = 42;`; + const ifStmt = factory.createIfStatement(test, thenStmt, elseStmt); + expect(generate(ifStmt).code).toEqual('if (!test) x = 10;else x = 42;'); + }); + + it('should create an if statement if the else expression is null', () => { + const test = expression.ast`!test`; + const thenStmt = statement.ast`x = 10;`; + const ifStmt = factory.createIfStatement(test, thenStmt, null); + expect(generate(ifStmt).code).toEqual('if (!test) x = 10;'); + }); + }); + + describe('createLiteral()', () => { + it('should create a string literal', () => { + const literal = factory.createLiteral('moo'); + expect(t.isStringLiteral(literal)).toBe(true); + expect(generate(literal).code).toEqual('"moo"'); + }); + + it('should create a number literal', () => { + const literal = factory.createLiteral(42); + expect(t.isNumericLiteral(literal)).toBe(true); + expect(generate(literal).code).toEqual('42'); + }); + + it('should create a number literal for `NaN`', () => { + const literal = factory.createLiteral(NaN); + expect(t.isNumericLiteral(literal)).toBe(true); + expect(generate(literal).code).toEqual('NaN'); + }); + + it('should create a boolean literal', () => { + const literal = factory.createLiteral(true); + expect(t.isBooleanLiteral(literal)).toBe(true); + expect(generate(literal).code).toEqual('true'); + }); + + it('should create an `undefined` literal', () => { + const literal = factory.createLiteral(undefined); + expect(t.isIdentifier(literal)).toBe(true); + expect(generate(literal).code).toEqual('undefined'); + }); + + it('should create a null literal', () => { + const literal = factory.createLiteral(null); + expect(t.isNullLiteral(literal)).toBe(true); + expect(generate(literal).code).toEqual('null'); + }); + }); + + describe('createNewExpression()', () => { + it('should create a `new` operation on the constructor `expression` with the given `args`', + () => { + const expr = expression.ast`Foo`; + const arg1 = expression.ast`42`; + const arg2 = expression.ast`"moo"`; + const call = factory.createNewExpression(expr, [arg1, arg2]); + expect(generate(call).code).toEqual('new Foo(42, "moo")'); + }); + }); + + describe('createObjectLiteral()', () => { + it('should create an object literal node, with the given properties', () => { + const prop1 = expression.ast`42`; + const prop2 = expression.ast`"moo"`; + const obj = factory.createObjectLiteral([ + {propertyName: 'prop1', value: prop1, quoted: false}, + {propertyName: 'prop2', value: prop2, quoted: true}, + ]); + expect(generate(obj).code).toEqual([ + '{', + ' prop1: 42,', + ' "prop2": "moo"', + '}', + ].join('\n')); + }); + }); + + describe('createParenthesizedExpression()', () => { + it('should add parentheses around the given expression', () => { + const expr = expression.ast`a + b`; + const paren = factory.createParenthesizedExpression(expr); + expect(generate(paren).code).toEqual('(a + b)'); + }); + }); + + describe('createPropertyAccess()', () => { + it('should create a property access expression node', () => { + const expr = expression.ast`obj`; + const access = factory.createPropertyAccess(expr, 'moo'); + expect(generate(access).code).toEqual('obj.moo'); + }); + }); + + describe('createReturnStatement()', () => { + it('should create a return statement returning the given expression', () => { + const expr = expression.ast`42`; + const returnStmt = factory.createReturnStatement(expr); + expect(generate(returnStmt).code).toEqual('return 42;'); + }); + + it('should create a void return statement if the expression is null', () => { + const returnStmt = factory.createReturnStatement(null); + expect(generate(returnStmt).code).toEqual('return;'); + }); + }); + + describe('createTaggedTemplate()', () => { + it('should create a tagged template node from the tag, elements and expressions', () => { + const elements = [ + {raw: 'raw1', cooked: 'cooked1', range: null}, + {raw: 'raw2', cooked: 'cooked2', range: null}, + {raw: 'raw3', cooked: 'cooked3', range: null}, + ]; + const expressions = [ + expression.ast`42`, + expression.ast`"moo"`, + ]; + const tag = expression.ast`tagFn`; + const template = factory.createTaggedTemplate(tag, {elements, expressions}); + expect(generate(template).code).toEqual('tagFn`raw1${42}raw2${"moo"}raw3`'); + }); + }); + + describe('createThrowStatement()', () => { + it('should create a throw statement, throwing the given expression', () => { + const expr = expression.ast`new Error("bad")`; + const throwStmt = factory.createThrowStatement(expr); + expect(generate(throwStmt).code).toEqual('throw new Error("bad");'); + }); + }); + + describe('createTypeOfExpression()', () => { + it('should create a typeof expression node', () => { + const expr = expression.ast`42`; + const typeofExpr = factory.createTypeOfExpression(expr); + expect(generate(typeofExpr).code).toEqual('typeof 42'); + }); + }); + + describe('createUnaryExpression()', () => { + it('should create a unary expression with the operator and operand', () => { + const expr = expression.ast`value`; + const unaryExpr = factory.createUnaryExpression('!', expr); + expect(generate(unaryExpr).code).toEqual('!value'); + }); + }); + + describe('createVariableDeclaration()', () => { + it('should create a variable declaration statement node for the given variable name and initializer', + () => { + const initializer = expression.ast`42`; + const varDecl = factory.createVariableDeclaration('foo', initializer, 'let'); + expect(generate(varDecl).code).toEqual('let foo = 42;'); + }); + + it('should create a constant declaration statement node for the given variable name and initializer', + () => { + const initializer = expression.ast`42`; + const varDecl = factory.createVariableDeclaration('foo', initializer, 'const'); + expect(generate(varDecl).code).toEqual('const foo = 42;'); + }); + + it('should create a downleveled variable declaration statement node for the given variable name and initializer', + () => { + const initializer = expression.ast`42`; + const varDecl = factory.createVariableDeclaration('foo', initializer, 'var'); + expect(generate(varDecl).code).toEqual('var foo = 42;'); + }); + + it('should create an uninitialized variable declaration statement node for the given variable name and a null initializer', + () => { + const varDecl = factory.createVariableDeclaration('foo', null, 'let'); + expect(generate(varDecl).code).toEqual('let foo;'); + }); + }); + + describe('setSourceMapRange()', () => { + it('should attach the `sourceMapRange` to the given `node`', () => { + const expr = expression.ast`42`; + expect(expr.loc).toBeUndefined(); + expect(expr.start).toBeUndefined(); + expect(expr.end).toBeUndefined(); + + factory.setSourceMapRange(expr, { + start: {line: 0, column: 1, offset: 1}, + end: {line: 2, column: 3, offset: 15}, + content: '-****\n*****\n****', + url: 'original.ts' + }); + + // Lines are 1-based in Babel. + expect(expr.loc).toEqual({ + start: {line: 1, column: 1}, + end: {line: 3, column: 3}, + }); + expect(expr.start).toEqual(1); + expect(expr.end).toEqual(15); + }); + }); +}); diff --git a/packages/compiler-cli/linker/test/ast/babel/babel_ast_host_spec.ts b/packages/compiler-cli/linker/test/ast/babel/babel_ast_host_spec.ts new file mode 100644 index 0000000000..2c371c10bf --- /dev/null +++ b/packages/compiler-cli/linker/test/ast/babel/babel_ast_host_spec.ts @@ -0,0 +1,305 @@ +/** + * @license + * Copyright Google LLC 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 t from '@babel/types'; +import template from '@babel/template'; +import {parse} from '@babel/parser'; +import {BabelAstHost} from '../../../src/ast/babel/babel_ast_host'; + +describe('BabelAstHost', () => { + let host: BabelAstHost; + beforeEach(() => host = new BabelAstHost()); + + describe('getSymbolName()', () => { + it('should return the name of an identifier', () => { + expect(host.getSymbolName(expr('someIdentifier'))).toEqual('someIdentifier'); + }); + + it('should return the name of an identifier at the end of a property access chain', () => { + expect(host.getSymbolName(expr('a.b.c.someIdentifier'))).toEqual('someIdentifier'); + }); + + it('should return null if the expression has no identifier', () => { + expect(host.getSymbolName(expr('42'))).toBe(null); + }); + }); + + describe('isStringLiteral()', () => { + it('should return true if the expression is a string literal', () => { + expect(host.isStringLiteral(expr('"moo"'))).toBe(true); + expect(host.isStringLiteral(expr('\'moo\''))).toBe(true); + }); + + it('should return false if the expression is not a string literal', () => { + expect(host.isStringLiteral(expr('true'))).toBe(false); + expect(host.isStringLiteral(expr('someIdentifier'))).toBe(false); + expect(host.isStringLiteral(expr('42'))).toBe(false); + expect(host.isStringLiteral(expr('{}'))).toBe(false); + expect(host.isStringLiteral(expr('[]'))).toBe(false); + expect(host.isStringLiteral(expr('null'))).toBe(false); + expect(host.isStringLiteral(expr('\'a\' + \'b\''))).toBe(false); + }); + + it('should return false if the expression is a template string', () => { + expect(host.isStringLiteral(expr('\`moo\`'))).toBe(false); + }); + }); + + describe('parseStringLiteral()', () => { + it('should extract the string value', () => { + expect(host.parseStringLiteral(expr('"moo"'))).toEqual('moo'); + expect(host.parseStringLiteral(expr('\'moo\''))).toEqual('moo'); + }); + + it('should error if the value is not a string literal', () => { + expect(() => host.parseStringLiteral(expr('42'))) + .toThrowError('Unsupported syntax, expected a string literal.'); + }); + }); + + describe('isNumericLiteral()', () => { + it('should return true if the expression is a number literal', () => { + expect(host.isNumericLiteral(expr('42'))).toBe(true); + }); + + it('should return false if the expression is not a number literal', () => { + expect(host.isStringLiteral(expr('true'))).toBe(false); + expect(host.isNumericLiteral(expr('"moo"'))).toBe(false); + expect(host.isNumericLiteral(expr('\'moo\''))).toBe(false); + expect(host.isNumericLiteral(expr('someIdentifier'))).toBe(false); + expect(host.isNumericLiteral(expr('{}'))).toBe(false); + expect(host.isNumericLiteral(expr('[]'))).toBe(false); + expect(host.isNumericLiteral(expr('null'))).toBe(false); + expect(host.isNumericLiteral(expr('\'a\' + \'b\''))).toBe(false); + expect(host.isNumericLiteral(expr('\`moo\`'))).toBe(false); + }); + }); + + describe('parseNumericLiteral()', () => { + it('should extract the number value', () => { + expect(host.parseNumericLiteral(expr('42'))).toEqual(42); + }); + + it('should error if the value is not a numeric literal', () => { + expect(() => host.parseNumericLiteral(expr('"moo"'))) + .toThrowError('Unsupported syntax, expected a numeric literal.'); + }); + }); + + describe('isBooleanLiteral()', () => { + it('should return true if the expression is a boolean literal', () => { + expect(host.isBooleanLiteral(expr('true'))).toBe(true); + expect(host.isBooleanLiteral(expr('false'))).toBe(true); + }); + + it('should return false if the expression is not a boolean literal', () => { + expect(host.isBooleanLiteral(expr('"moo"'))).toBe(false); + expect(host.isBooleanLiteral(expr('\'moo\''))).toBe(false); + expect(host.isBooleanLiteral(expr('someIdentifier'))).toBe(false); + expect(host.isBooleanLiteral(expr('42'))).toBe(false); + expect(host.isBooleanLiteral(expr('{}'))).toBe(false); + expect(host.isBooleanLiteral(expr('[]'))).toBe(false); + expect(host.isBooleanLiteral(expr('null'))).toBe(false); + expect(host.isBooleanLiteral(expr('\'a\' + \'b\''))).toBe(false); + expect(host.isBooleanLiteral(expr('\`moo\`'))).toBe(false); + }); + }); + + describe('parseBooleanLiteral()', () => { + it('should extract the boolean value', () => { + expect(host.parseBooleanLiteral(expr('true'))).toEqual(true); + expect(host.parseBooleanLiteral(expr('false'))).toEqual(false); + }); + + it('should error if the value is not a boolean literal', () => { + expect(() => host.parseBooleanLiteral(expr('"moo"'))) + .toThrowError('Unsupported syntax, expected a boolean literal.'); + }); + }); + + describe('isArrayLiteral()', () => { + it('should return true if the expression is an array literal', () => { + expect(host.isArrayLiteral(expr('[]'))).toBe(true); + expect(host.isArrayLiteral(expr('[1, 2, 3]'))).toBe(true); + expect(host.isArrayLiteral(expr('[[], []]'))).toBe(true); + }); + + it('should return false if the expression is not an array literal', () => { + expect(host.isArrayLiteral(expr('"moo"'))).toBe(false); + expect(host.isArrayLiteral(expr('\'moo\''))).toBe(false); + expect(host.isArrayLiteral(expr('someIdentifier'))).toBe(false); + expect(host.isArrayLiteral(expr('42'))).toBe(false); + expect(host.isArrayLiteral(expr('{}'))).toBe(false); + expect(host.isArrayLiteral(expr('null'))).toBe(false); + expect(host.isArrayLiteral(expr('\'a\' + \'b\''))).toBe(false); + expect(host.isArrayLiteral(expr('\`moo\`'))).toBe(false); + }); + }); + + describe('parseArrayLiteral()', () => { + it('should extract the expressions in the array', () => { + const moo = expr('\'moo\''); + expect(host.parseArrayLiteral(expr('[]'))).toEqual([]); + expect(host.parseArrayLiteral(expr('[\'moo\']'))).toEqual([moo]); + }); + + it('should error if there is an empty item', () => { + expect(() => host.parseArrayLiteral(expr('[,]'))) + .toThrowError('Unsupported syntax, expected element in array not to be empty.'); + }); + + it('should error if there is a spread element', () => { + expect(() => host.parseArrayLiteral(expr('[...[0,1]]'))) + .toThrowError('Unsupported syntax, expected element in array not to use spread syntax.'); + }); + }); + + describe('isObjectLiteral()', () => { + it('should return true if the expression is an object literal', () => { + expect(host.isObjectLiteral(rhs('x = {}'))).toBe(true); + expect(host.isObjectLiteral(rhs('x = { foo: \'bar\' }'))).toBe(true); + }); + + it('should return false if the expression is not an object literal', () => { + expect(host.isObjectLiteral(rhs('x = "moo"'))).toBe(false); + expect(host.isObjectLiteral(rhs('x = \'moo\''))).toBe(false); + expect(host.isObjectLiteral(rhs('x = someIdentifier'))).toBe(false); + expect(host.isObjectLiteral(rhs('x = 42'))).toBe(false); + expect(host.isObjectLiteral(rhs('x = []'))).toBe(false); + expect(host.isObjectLiteral(rhs('x = null'))).toBe(false); + expect(host.isObjectLiteral(rhs('x = \'a\' + \'b\''))).toBe(false); + expect(host.isObjectLiteral(rhs('x = \`moo\`'))).toBe(false); + }); + }); + + describe('parseObjectLiteral()', () => { + it('should extract the properties from the object', () => { + const moo = expr('\'moo\''); + expect(host.parseObjectLiteral(rhs('x = {}'))).toEqual(new Map()); + expect(host.parseObjectLiteral(rhs('x = {a: \'moo\'}'))).toEqual(new Map([['a', moo]])); + }); + + it('should error if there is a method', () => { + expect(() => host.parseObjectLiteral(rhs('x = { foo() {} }'))) + .toThrowError('Unsupported syntax, expected a property assignment.'); + }); + + it('should error if there is a spread element', () => { + expect(() => host.parseObjectLiteral(rhs('x = {...{a:\'moo\'}}'))) + .toThrowError('Unsupported syntax, expected a property assignment.'); + }); + }); + + describe('isFunctionExpression()', () => { + it('should return true if the expression is a function', () => { + expect(host.isFunctionExpression(rhs('x = function() {}'))).toBe(true); + expect(host.isFunctionExpression(rhs('x = function foo() {}'))).toBe(true); + expect(host.isFunctionExpression(rhs('x = () => {}'))).toBe(true); + expect(host.isFunctionExpression(rhs('x = () => true'))).toBe(true); + }); + + it('should return false if the expression is a function declaration', () => { + expect(host.isFunctionExpression(expr('function foo() {}'))).toBe(false); + }); + + it('should return false if the expression is not a function expression', () => { + expect(host.isFunctionExpression(expr('[]'))).toBe(false); + expect(host.isFunctionExpression(expr('"moo"'))).toBe(false); + expect(host.isFunctionExpression(expr('\'moo\''))).toBe(false); + expect(host.isFunctionExpression(expr('someIdentifier'))).toBe(false); + expect(host.isFunctionExpression(expr('42'))).toBe(false); + expect(host.isFunctionExpression(expr('{}'))).toBe(false); + expect(host.isFunctionExpression(expr('null'))).toBe(false); + expect(host.isFunctionExpression(expr('\'a\' + \'b\''))).toBe(false); + expect(host.isFunctionExpression(expr('\`moo\`'))).toBe(false); + }); + }); + + describe('parseReturnValue()', () => { + it('should extract the return value of a function', () => { + const moo = expr('\'moo\''); + expect(host.parseReturnValue(rhs('x = function() { return \'moo\'; }'))).toEqual(moo); + }); + + it('should extract the value of a simple arrow function', () => { + const moo = expr('\'moo\''); + expect(host.parseReturnValue(rhs('x = () => \'moo\''))).toEqual(moo); + }); + + it('should extract the return value of an arrow function', () => { + const moo = expr('\'moo\''); + expect(host.parseReturnValue(rhs('x = () => { return \'moo\' }'))).toEqual(moo); + }); + + it('should error if the body has 0 statements', () => { + expect(() => host.parseReturnValue(rhs('x = function () { }'))) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + expect(() => host.parseReturnValue(rhs('x = () => { }'))) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + }); + + it('should error if the body has more than 1 statement', () => { + expect(() => host.parseReturnValue(rhs('x = function () { const x = 10; return x; }'))) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + expect(() => host.parseReturnValue(rhs('x = () => { const x = 10; return x; }'))) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + }); + + it('should error if the single statement is not a return statement', () => { + expect(() => host.parseReturnValue(rhs('x = function () { const x = 10; }'))) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + expect(() => host.parseReturnValue(rhs('x = () => { const x = 10; }'))) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + }); + }); + + describe('getRange()', () => { + it('should extract the range from the expression', () => { + const file = parse('// preamble\nx = \'moo\';'); + const stmt = file.program.body[0]; + assertExpressionStatement(stmt); + assertAssignmentExpression(stmt.expression); + expect(host.getRange(stmt.expression.right)) + .toEqual({startLine: 1, startCol: 4, startPos: 16, endPos: 21}); + }); + + it('should error if there is no range information', () => { + const moo = rhs('// preamble\nx = \'moo\';'); + expect(() => host.getRange(moo)) + .toThrowError('Unable to read range for node - it is missing location information.'); + }); + }); +}); + +function expr(code: string): t.Expression { + const stmt = template.ast(code); + return (stmt as t.ExpressionStatement).expression; +} + +function rhs(code: string): t.Expression { + const e = expr(code); + assertAssignmentExpression(e); + return e.right; +} + +function assertExpressionStatement(e: t.Node): asserts e is t.ExpressionStatement { + if (!t.isExpressionStatement(e)) { + throw new Error('Bad test - expected an expression statement'); + } +} + +function assertAssignmentExpression(e: t.Expression): asserts e is t.AssignmentExpression { + if (!t.isAssignmentExpression(e)) { + throw new Error('Bad test - expected an assignment expression'); + } +} diff --git a/packages/compiler-cli/linker/test/ast/typescript/typescript_ast_host_spec.ts b/packages/compiler-cli/linker/test/ast/typescript/typescript_ast_host_spec.ts new file mode 100644 index 0000000000..68dd6ad9b4 --- /dev/null +++ b/packages/compiler-cli/linker/test/ast/typescript/typescript_ast_host_spec.ts @@ -0,0 +1,289 @@ +/** + * @license + * Copyright Google LLC 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 {TypeScriptAstHost} from '../../../src/ast/typescript/typescript_ast_host'; + +describe('TypeScriptAstHost', () => { + let host: TypeScriptAstHost; + beforeEach(() => host = new TypeScriptAstHost()); + + describe('getSymbolName()', () => { + it('should return the name of an identifier', () => { + expect(host.getSymbolName(expr('someIdentifier'))).toEqual('someIdentifier'); + }); + + it('should return the name of an identifier at the end of a property access chain', () => { + expect(host.getSymbolName(expr('a.b.c.someIdentifier'))).toEqual('someIdentifier'); + }); + + it('should return null if the expression has no identifier', () => { + expect(host.getSymbolName(expr('42'))).toBe(null); + }); + }); + + describe('isStringLiteral()', () => { + it('should return true if the expression is a string literal', () => { + expect(host.isStringLiteral(expr('"moo"'))).toBe(true); + expect(host.isStringLiteral(expr('\'moo\''))).toBe(true); + }); + + it('should return false if the expression is not a string literal', () => { + expect(host.isStringLiteral(expr('true'))).toBe(false); + expect(host.isStringLiteral(expr('someIdentifier'))).toBe(false); + expect(host.isStringLiteral(expr('42'))).toBe(false); + expect(host.isStringLiteral(rhs('x = {}'))).toBe(false); + expect(host.isStringLiteral(expr('[]'))).toBe(false); + expect(host.isStringLiteral(expr('null'))).toBe(false); + expect(host.isStringLiteral(expr('\'a\' + \'b\''))).toBe(false); + }); + + it('should return false if the expression is a template string', () => { + expect(host.isStringLiteral(expr('\`moo\`'))).toBe(false); + }); + }); + + describe('parseStringLiteral()', () => { + it('should extract the string value', () => { + expect(host.parseStringLiteral(expr('"moo"'))).toEqual('moo'); + expect(host.parseStringLiteral(expr('\'moo\''))).toEqual('moo'); + }); + + it('should error if the value is not a string literal', () => { + expect(() => host.parseStringLiteral(expr('42'))) + .toThrowError('Unsupported syntax, expected a string literal.'); + }); + }); + + describe('isNumericLiteral()', () => { + it('should return true if the expression is a number literal', () => { + expect(host.isNumericLiteral(expr('42'))).toBe(true); + }); + + it('should return false if the expression is not a number literal', () => { + expect(host.isStringLiteral(expr('true'))).toBe(false); + expect(host.isNumericLiteral(expr('"moo"'))).toBe(false); + expect(host.isNumericLiteral(expr('\'moo\''))).toBe(false); + expect(host.isNumericLiteral(expr('someIdentifier'))).toBe(false); + expect(host.isNumericLiteral(rhs('x = {}'))).toBe(false); + expect(host.isNumericLiteral(expr('[]'))).toBe(false); + expect(host.isNumericLiteral(expr('null'))).toBe(false); + expect(host.isNumericLiteral(expr('\'a\' + \'b\''))).toBe(false); + expect(host.isNumericLiteral(expr('\`moo\`'))).toBe(false); + }); + }); + + describe('parseNumericLiteral()', () => { + it('should extract the number value', () => { + expect(host.parseNumericLiteral(expr('42'))).toEqual(42); + }); + + it('should error if the value is not a numeric literal', () => { + expect(() => host.parseNumericLiteral(expr('"moo"'))) + .toThrowError('Unsupported syntax, expected a numeric literal.'); + }); + }); + + describe('isBooleanLiteral()', () => { + it('should return true if the expression is a boolean literal', () => { + expect(host.isBooleanLiteral(expr('true'))).toBe(true); + expect(host.isBooleanLiteral(expr('false'))).toBe(true); + }); + + it('should return false if the expression is not a boolean literal', () => { + expect(host.isBooleanLiteral(expr('"moo"'))).toBe(false); + expect(host.isBooleanLiteral(expr('\'moo\''))).toBe(false); + expect(host.isBooleanLiteral(expr('someIdentifier'))).toBe(false); + expect(host.isBooleanLiteral(expr('42'))).toBe(false); + expect(host.isBooleanLiteral(rhs('x = {}'))).toBe(false); + expect(host.isBooleanLiteral(expr('[]'))).toBe(false); + expect(host.isBooleanLiteral(expr('null'))).toBe(false); + expect(host.isBooleanLiteral(expr('\'a\' + \'b\''))).toBe(false); + expect(host.isBooleanLiteral(expr('\`moo\`'))).toBe(false); + }); + }); + + describe('parseBooleanLiteral()', () => { + it('should extract the boolean value', () => { + expect(host.parseBooleanLiteral(expr('true'))).toEqual(true); + expect(host.parseBooleanLiteral(expr('false'))).toEqual(false); + }); + + it('should error if the value is not a boolean literal', () => { + expect(() => host.parseBooleanLiteral(expr('"moo"'))) + .toThrowError('Unsupported syntax, expected a boolean literal.'); + }); + }); + + describe('isArrayLiteral()', () => { + it('should return true if the expression is an array literal', () => { + expect(host.isArrayLiteral(expr('[]'))).toBe(true); + expect(host.isArrayLiteral(expr('[1, 2, 3]'))).toBe(true); + expect(host.isArrayLiteral(expr('[[], []]'))).toBe(true); + }); + + it('should return false if the expression is not an array literal', () => { + expect(host.isArrayLiteral(expr('"moo"'))).toBe(false); + expect(host.isArrayLiteral(expr('\'moo\''))).toBe(false); + expect(host.isArrayLiteral(expr('someIdentifier'))).toBe(false); + expect(host.isArrayLiteral(expr('42'))).toBe(false); + expect(host.isArrayLiteral(rhs('x = {}'))).toBe(false); + expect(host.isArrayLiteral(expr('null'))).toBe(false); + expect(host.isArrayLiteral(expr('\'a\' + \'b\''))).toBe(false); + expect(host.isArrayLiteral(expr('\`moo\`'))).toBe(false); + }); + }); + + describe('parseArrayLiteral()', () => { + it('should extract the expressions in the array', () => { + const moo = jasmine.objectContaining({text: 'moo', kind: ts.SyntaxKind.StringLiteral}); + expect(host.parseArrayLiteral(expr('[]'))).toEqual([]); + expect(host.parseArrayLiteral(expr('[\'moo\']'))).toEqual([moo]); + }); + + it('should error if there is an empty item', () => { + expect(() => host.parseArrayLiteral(expr('[,]'))) + .toThrowError('Unsupported syntax, expected element in array not to be empty.'); + }); + + it('should error if there is a spread element', () => { + expect(() => host.parseArrayLiteral(expr('[...[0,1]]'))) + .toThrowError('Unsupported syntax, expected element in array not to use spread syntax.'); + }); + }); + + describe('isObjectLiteral()', () => { + it('should return true if the expression is an object literal', () => { + expect(host.isObjectLiteral(rhs('x = {}'))).toBe(true); + expect(host.isObjectLiteral(rhs('x = { foo: \'bar\' }'))).toBe(true); + }); + + it('should return false if the expression is not an object literal', () => { + expect(host.isObjectLiteral(rhs('x = "moo"'))).toBe(false); + expect(host.isObjectLiteral(rhs('x = \'moo\''))).toBe(false); + expect(host.isObjectLiteral(rhs('x = someIdentifier'))).toBe(false); + expect(host.isObjectLiteral(rhs('x = 42'))).toBe(false); + expect(host.isObjectLiteral(rhs('x = []'))).toBe(false); + expect(host.isObjectLiteral(rhs('x = null'))).toBe(false); + expect(host.isObjectLiteral(rhs('x = \'a\' + \'b\''))).toBe(false); + expect(host.isObjectLiteral(rhs('x = \`moo\`'))).toBe(false); + }); + }); + + describe('parseObjectLiteral()', () => { + it('should extract the properties from the object', () => { + const moo = jasmine.objectContaining({text: 'moo', kind: ts.SyntaxKind.StringLiteral}); + expect(host.parseObjectLiteral(rhs('x = {}'))).toEqual(new Map()); + expect(host.parseObjectLiteral(rhs('x = {a: \'moo\'}'))).toEqual(new Map([['a', moo]])); + }); + + it('should error if there is a method', () => { + expect(() => host.parseObjectLiteral(rhs('x = { foo() {} }'))) + .toThrowError('Unsupported syntax, expected a property assignment.'); + }); + + it('should error if there is a spread element', () => { + expect(() => host.parseObjectLiteral(rhs('x = {...{ a: \'moo\' }}'))) + .toThrowError('Unsupported syntax, expected a property assignment.'); + }); + }); + + describe('isFunctionExpression()', () => { + it('should return true if the expression is a function', () => { + expect(host.isFunctionExpression(rhs('x = function() {}'))).toBe(true); + expect(host.isFunctionExpression(rhs('x = function foo() {}'))).toBe(true); + expect(host.isFunctionExpression(rhs('x = () => {}'))).toBe(true); + expect(host.isFunctionExpression(rhs('x = () => true'))).toBe(true); + }); + + it('should return false if the expression is not a function', () => { + expect(host.isFunctionExpression(expr('[]'))).toBe(false); + expect(host.isFunctionExpression(expr('"moo"'))).toBe(false); + expect(host.isFunctionExpression(expr('\'moo\''))).toBe(false); + expect(host.isFunctionExpression(expr('someIdentifier'))).toBe(false); + expect(host.isFunctionExpression(expr('42'))).toBe(false); + expect(host.isFunctionExpression(rhs('x = {}'))).toBe(false); + expect(host.isFunctionExpression(expr('null'))).toBe(false); + expect(host.isFunctionExpression(expr('\'a\' + \'b\''))).toBe(false); + expect(host.isFunctionExpression(expr('\`moo\`'))).toBe(false); + }); + }); + + describe('parseReturnValue()', () => { + it('should extract the return value of a function', () => { + const moo = jasmine.objectContaining({text: 'moo', kind: ts.SyntaxKind.StringLiteral}); + expect(host.parseReturnValue(rhs('x = function() { return \'moo\'; }'))).toEqual(moo); + }); + + it('should extract the value of a simple arrow function', () => { + const moo = jasmine.objectContaining({text: 'moo', kind: ts.SyntaxKind.StringLiteral}); + expect(host.parseReturnValue(rhs('x = () => \'moo\''))).toEqual(moo); + }); + + it('should extract the return value of an arrow function', () => { + const moo = jasmine.objectContaining({text: 'moo', kind: ts.SyntaxKind.StringLiteral}); + expect(host.parseReturnValue(rhs('x = () => { return \'moo\' }'))).toEqual(moo); + }); + + it('should error if the body has 0 statements', () => { + expect(() => host.parseReturnValue(rhs('x = function () { }'))) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + expect(() => host.parseReturnValue(rhs('x = () => { }'))) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + }); + + it('should error if the body has more than 1 statement', () => { + expect(() => host.parseReturnValue(rhs('x = function () { const x = 10; return x; }'))) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + expect(() => host.parseReturnValue(rhs('x = () => { const x = 10; return x; }'))) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + }); + + it('should error if the single statement is not a return statement', () => { + expect(() => host.parseReturnValue(rhs('x = function () { const x = 10; }'))) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + expect(() => host.parseReturnValue(rhs('x = () => { const x = 10; }'))) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + }); + }); + + describe('getRange()', () => { + it('should extract the range from the expression', () => { + const moo = rhs('// preamble\nx = \'moo\';'); + expect(host.getRange(moo)).toEqual({startLine: 1, startCol: 4, startPos: 16, endPos: 21}); + }); + + it('should error if the nodes do not have attached parents', () => { + const moo = rhs('// preamble\nx = \'moo\';', false); + expect(() => host.getRange(moo)) + .toThrowError('Unable to read range for node - it is missing parent information.'); + }); + }); +}); + +function stmt(code: string, attachParents = true): ts.Statement { + const sf = ts.createSourceFile('test.ts', code, ts.ScriptTarget.ES2015, attachParents); + return sf.statements[0]; +} + +function expr(code: string, attachParents = true): ts.Expression { + return (stmt(code, attachParents) as ts.ExpressionStatement).expression; +} + +function rhs(code: string, attachParents = true): ts.Expression { + const e = expr(code, attachParents); + if (!ts.isBinaryExpression(e)) { + throw new Error('Bad test - expected a binary expression'); + } + return e.right; +} diff --git a/packages/compiler-cli/linker/test/fatal_linker_error_spec.ts b/packages/compiler-cli/linker/test/fatal_linker_error_spec.ts new file mode 100644 index 0000000000..1b4572fff8 --- /dev/null +++ b/packages/compiler-cli/linker/test/fatal_linker_error_spec.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright Google LLC 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 {FatalLinkerError, isFatalLinkerError} from '../src/fatal_linker_error'; + +describe('FatalLinkerError', () => { + it('should expose the `node` and `message`', () => { + const node = {}; + expect(new FatalLinkerError(node, 'Some message')) + .toEqual(jasmine.objectContaining({node, message: 'Some message'})); + }); +}); + +describe('isFatalLinkerError()', () => { + it('should return true if the error is of type `FatalLinkerError`', () => { + const error = new FatalLinkerError({}, 'Some message'); + expect(isFatalLinkerError(error)).toBe(true); + }); + + it('should return false if the error is not of type `FatalLinkerError`', () => { + const error = new Error('Some message'); + expect(isFatalLinkerError(error)).toBe(false); + }); +}); diff --git a/packages/compiler-cli/linker/test/linker_import_generator_spec.ts b/packages/compiler-cli/linker/test/linker_import_generator_spec.ts new file mode 100644 index 0000000000..bb4af55fbe --- /dev/null +++ b/packages/compiler-cli/linker/test/linker_import_generator_spec.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC 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 {LinkerImportGenerator} from '../src/linker_import_generator'; + +const ngImport = { + type: 'ngImport' +}; + +describe('LinkerImportGenerator', () => { + describe('generateNamespaceImport()', () => { + it('should error if the import is not `@angular/core`', () => { + const generator = new LinkerImportGenerator(ngImport); + expect(() => generator.generateNamespaceImport('other/import')) + .toThrowError(`Unable to import from anything other than '@angular/core'`); + }); + + it('should return the ngImport expression for `@angular/core`', () => { + const generator = new LinkerImportGenerator(ngImport); + expect(generator.generateNamespaceImport('@angular/core')).toBe(ngImport); + }); + }); + + describe('generateNamedImport()', () => { + it('should error if the import is not `@angular/core`', () => { + const generator = new LinkerImportGenerator(ngImport); + expect(() => generator.generateNamedImport('other/import', 'someSymbol')) + .toThrowError(`Unable to import from anything other than '@angular/core'`); + }); + + it('should return a `NamedImport` object containing the ngImport expression', () => { + const generator = new LinkerImportGenerator(ngImport); + expect(generator.generateNamedImport('@angular/core', 'someSymbol')) + .toEqual({moduleImport: ngImport, symbol: 'someSymbol'}); + }); + }); +}); diff --git a/packages/compiler-cli/package.json b/packages/compiler-cli/package.json index d1940a59d3..dbe7308d95 100644 --- a/packages/compiler-cli/package.json +++ b/packages/compiler-cli/package.json @@ -11,6 +11,8 @@ "ng-xi18n": "./src/extract_i18n.js" }, "dependencies": { + "@babel/core": "^7.8.6", + "@babel/types": "^7.8.6", "reflect-metadata": "^0.1.2", "minimist": "^1.2.0", "canonical-path": "1.0.0", @@ -52,4 +54,4 @@ "publishConfig": { "registry": "https://wombat-dressing-room.appspot.com" } -} \ No newline at end of file +} diff --git a/packages/compiler-cli/src/ngtsc/translator/index.ts b/packages/compiler-cli/src/ngtsc/translator/index.ts index 0df3249b78..81e21a189a 100644 --- a/packages/compiler-cli/src/ngtsc/translator/index.ts +++ b/packages/compiler-cli/src/ngtsc/translator/index.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -export {AstFactory, BinaryOperator, LeadingComment, ObjectLiteralProperty, SourceMapLocation, SourceMapRange, TemplateElement, TemplateLiteral, UnaryOperator} from './src/api/ast_factory'; +export {AstFactory, BinaryOperator, LeadingComment, ObjectLiteralProperty, SourceMapLocation, SourceMapRange, TemplateElement, TemplateLiteral, UnaryOperator, VariableDeclarationType} from './src/api/ast_factory'; export {Import, ImportGenerator, NamedImport} from './src/api/import_generator'; export {ImportManager} from './src/import_manager'; export {RecordWrappedNodeExprFn} from './src/translator'; diff --git a/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts b/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts index d887e0d4a5..fb4325dd6d 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts @@ -21,7 +21,7 @@ export interface AstFactory { * @param leadingComments the comments to attach. * @returns the node passed in as `statement` with the comments attached. */ - attachComments(statement: TStatement, leadingComments?: LeadingComment[]): TStatement; + attachComments(statement: TStatement, leadingComments: LeadingComment[]|undefined): TStatement; /** * Create a literal array expresion (e.g. `[expr1, expr2]`). diff --git a/packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts b/packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts index b039e0c954..aa0204dadd 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts @@ -235,7 +235,7 @@ export function createTemplateTail(cooked: string, raw: string): ts.TemplateTail * @param leadingComments The comments to attach to the statement. */ export function attachComments( - statement: T, leadingComments?: LeadingComment[]): T { + statement: T, leadingComments: LeadingComment[]|undefined): T { if (leadingComments === undefined) { return statement; }