From 7e742aea7c9fba3155dd09c0e2e7a59eb2b7f8c1 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Sat, 3 Oct 2020 19:59:53 +0100 Subject: [PATCH] refactor(compiler-cli): linker - add Babel plugin, FileLinker and initial PartialLinkers (#39116) This commit adds the basic building blocks for linking partial declarations. In particular it provides a generic `FileLinker` class that delegates to a set of (not yet implemented) `PartialLinker` classes. The Babel plugin makes use of this `FileLinker` providing concrete classes for `AstHost` and `AstFactory` that work with Babel AST. It can be created with the following code: ```ts const plugin = createEs2015LinkerPlugin({ /* options */ }); ``` PR Close #39116 --- packages/compiler-cli/linker/BUILD.bazel | 5 +- packages/compiler-cli/linker/README.md | 18 + .../compiler-cli/linker/babel/BUILD.bazel | 19 ++ packages/compiler-cli/linker/babel/README.md | 12 + packages/compiler-cli/linker/babel/index.ts | 8 + .../src/ast}/babel_ast_factory.ts | 2 +- .../babel => babel/src/ast}/babel_ast_host.ts | 4 +- .../babel/src/babel_declaration_scope.ts | 67 ++++ .../linker/babel/src/es2015_linker_plugin.ts | 167 +++++++++ .../linker/babel/test/BUILD.bazel | 36 ++ .../test/ast}/babel_ast_factory_spec.ts | 2 +- .../test/ast}/babel_ast_host_spec.ts | 2 +- .../test/babel_declaration_scope_spec.ts | 116 +++++++ .../babel/test/es2015_linker_plugin_spec.ts | 256 ++++++++++++++ packages/compiler-cli/linker/index.ts | 7 + .../compiler-cli/linker/src/ast/ast_value.ts | 240 +++++++++++++ .../linker/src/fatal_linker_error.ts | 2 +- .../src/file_linker/declaration_scope.ts | 45 +++ .../src/file_linker/emit_scopes/emit_scope.ts | 48 +++ .../emit_scopes/iife_emit_scope.ts | 40 +++ .../linker/src/file_linker/file_linker.ts | 94 ++++++ .../src/file_linker/linker_environment.ts | 25 ++ .../linker/src/file_linker/linker_options.ts | 31 ++ .../partial_component_linker_1.ts | 25 ++ .../partial_directive_linker_1.ts | 25 ++ .../partial_linkers/partial_linker.ts | 22 ++ .../partial_linker_selector.ts | 44 +++ .../linker/src/file_linker/translator.ts | 39 +++ .../linker/src/linker_import_generator.ts | 5 +- packages/compiler-cli/linker/test/BUILD.bazel | 9 +- .../linker/test/ast/ast_value_spec.ts | 318 ++++++++++++++++++ .../emit_scopes/emit_scope_spec.ts | 77 +++++ .../emit_scopes/iief_emit_scope_spec.ts | 71 ++++ .../test/file_linker/file_linker_spec.ts | 191 +++++++++++ .../linker/test/file_linker/helpers.ts | 17 + .../partial_linker_selector_spec.ts | 41 +++ .../test/file_linker/translator_spec.ts | 47 +++ .../test/linker_import_generator_spec.ts | 2 +- .../src/ngtsc/translator/index.ts | 3 +- 39 files changed, 2159 insertions(+), 23 deletions(-) create mode 100644 packages/compiler-cli/linker/README.md create mode 100644 packages/compiler-cli/linker/babel/BUILD.bazel create mode 100644 packages/compiler-cli/linker/babel/README.md create mode 100644 packages/compiler-cli/linker/babel/index.ts rename packages/compiler-cli/linker/{src/ast/babel => babel/src/ast}/babel_ast_factory.ts (99%) rename packages/compiler-cli/linker/{src/ast/babel => babel/src/ast}/babel_ast_host.ts (97%) create mode 100644 packages/compiler-cli/linker/babel/src/babel_declaration_scope.ts create mode 100644 packages/compiler-cli/linker/babel/src/es2015_linker_plugin.ts create mode 100644 packages/compiler-cli/linker/babel/test/BUILD.bazel rename packages/compiler-cli/linker/{test/ast/babel => babel/test/ast}/babel_ast_factory_spec.ts (99%) rename packages/compiler-cli/linker/{test/ast/babel => babel/test/ast}/babel_ast_host_spec.ts (99%) create mode 100644 packages/compiler-cli/linker/babel/test/babel_declaration_scope_spec.ts create mode 100644 packages/compiler-cli/linker/babel/test/es2015_linker_plugin_spec.ts create mode 100644 packages/compiler-cli/linker/src/ast/ast_value.ts create mode 100644 packages/compiler-cli/linker/src/file_linker/declaration_scope.ts create mode 100644 packages/compiler-cli/linker/src/file_linker/emit_scopes/emit_scope.ts create mode 100644 packages/compiler-cli/linker/src/file_linker/emit_scopes/iife_emit_scope.ts create mode 100644 packages/compiler-cli/linker/src/file_linker/file_linker.ts create mode 100644 packages/compiler-cli/linker/src/file_linker/linker_environment.ts create mode 100644 packages/compiler-cli/linker/src/file_linker/linker_options.ts create mode 100644 packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_component_linker_1.ts create mode 100644 packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_directive_linker_1.ts create mode 100644 packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_linker.ts create mode 100644 packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_linker_selector.ts create mode 100644 packages/compiler-cli/linker/src/file_linker/translator.ts create mode 100644 packages/compiler-cli/linker/test/ast/ast_value_spec.ts create mode 100644 packages/compiler-cli/linker/test/file_linker/emit_scopes/emit_scope_spec.ts create mode 100644 packages/compiler-cli/linker/test/file_linker/emit_scopes/iief_emit_scope_spec.ts create mode 100644 packages/compiler-cli/linker/test/file_linker/file_linker_spec.ts create mode 100644 packages/compiler-cli/linker/test/file_linker/helpers.ts create mode 100644 packages/compiler-cli/linker/test/file_linker/partial_linkers/partial_linker_selector_spec.ts create mode 100644 packages/compiler-cli/linker/test/file_linker/translator_spec.ts diff --git a/packages/compiler-cli/linker/BUILD.bazel b/packages/compiler-cli/linker/BUILD.bazel index 9324fa61a9..0c9fb1309d 100644 --- a/packages/compiler-cli/linker/BUILD.bazel +++ b/packages/compiler-cli/linker/BUILD.bazel @@ -8,11 +8,8 @@ ts_library( "src/**/*.ts", ]), deps = [ + "//packages/compiler", "//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/README.md b/packages/compiler-cli/linker/README.md new file mode 100644 index 0000000000..d063084c3b --- /dev/null +++ b/packages/compiler-cli/linker/README.md @@ -0,0 +1,18 @@ +# Angular Linker + +This package contains a `FileLinker` and supporting code to be able to "link" partial declarations of components, directives, etc in libraries to produce the full definitions. + +The partial declaration format allows library packages to be published to npm without exposing the underlying Ivy instructions. + +The tooling here allows application build tools (e.g. CLI) to produce fully compiled components, directives, etc at the point when the application is bundled. +These linked files can be cached outside `node_modules` so it does not suffer from problems of mutating packages in `node_modules`. + +Generally this tooling will be wrapped in a transpiler specific plugin, such as the provided [Babel plugin](./babel). + +## Unit Testing + +The unit tests are built and run using Bazel: + +```bash +yarn bazel test //packages/compiler-cli/linker/test +``` diff --git a/packages/compiler-cli/linker/babel/BUILD.bazel b/packages/compiler-cli/linker/babel/BUILD.bazel new file mode 100644 index 0000000000..b5a15beef3 --- /dev/null +++ b/packages/compiler-cli/linker/babel/BUILD.bazel @@ -0,0 +1,19 @@ +load("//tools:defaults.bzl", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "babel", + srcs = ["index.ts"] + glob([ + "src/**/*.ts", + ]), + deps = [ + "//packages/compiler", + "//packages/compiler-cli/linker", + "//packages/compiler-cli/src/ngtsc/translator", + "@npm//@babel/core", + "@npm//@babel/types", + "@npm//@types/babel__core", + "@npm//@types/babel__traverse", + ], +) diff --git a/packages/compiler-cli/linker/babel/README.md b/packages/compiler-cli/linker/babel/README.md new file mode 100644 index 0000000000..374cc55d07 --- /dev/null +++ b/packages/compiler-cli/linker/babel/README.md @@ -0,0 +1,12 @@ +# Angular linker - Babel plugin + +This package contains a Babel plugin that can be used to find and link partially compiled declarations in library source code. +See the [linker package README](../README.md) for more information. + +## Unit Testing + +The unit tests are built and run using Bazel: + +```bash +yarn bazel test //packages/compiler-cli/linker/babel/test +``` diff --git a/packages/compiler-cli/linker/babel/index.ts b/packages/compiler-cli/linker/babel/index.ts new file mode 100644 index 0000000000..cf9450bf4b --- /dev/null +++ b/packages/compiler-cli/linker/babel/index.ts @@ -0,0 +1,8 @@ +/** + * @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 + */ +export {createEs2015LinkerPlugin} from './src/es2015_linker_plugin'; \ No newline at end of file diff --git a/packages/compiler-cli/linker/src/ast/babel/babel_ast_factory.ts b/packages/compiler-cli/linker/babel/src/ast/babel_ast_factory.ts similarity index 99% rename from packages/compiler-cli/linker/src/ast/babel/babel_ast_factory.ts rename to packages/compiler-cli/linker/babel/src/ast/babel_ast_factory.ts index afefce4c1f..811f8a9bac 100644 --- a/packages/compiler-cli/linker/src/ast/babel/babel_ast_factory.ts +++ b/packages/compiler-cli/linker/babel/src/ast/babel_ast_factory.ts @@ -7,8 +7,8 @@ */ import * as t from '@babel/types'; +import {assert} from '../../../../linker'; import {AstFactory, BinaryOperator, LeadingComment, ObjectLiteralProperty, SourceMapRange, TemplateLiteral, VariableDeclarationType} from '../../../../src/ngtsc/translator'; -import {assert} from '../utils'; /** * A Babel flavored implementation of the AstFactory. diff --git a/packages/compiler-cli/linker/src/ast/babel/babel_ast_host.ts b/packages/compiler-cli/linker/babel/src/ast/babel_ast_host.ts similarity index 97% rename from packages/compiler-cli/linker/src/ast/babel/babel_ast_host.ts rename to packages/compiler-cli/linker/babel/src/ast/babel_ast_host.ts index a31ba6a536..4e826d8028 100644 --- a/packages/compiler-cli/linker/src/ast/babel/babel_ast_host.ts +++ b/packages/compiler-cli/linker/babel/src/ast/babel_ast_host.ts @@ -8,9 +8,7 @@ import * as t from '@babel/types'; -import {FatalLinkerError} from '../../fatal_linker_error'; -import {AstHost, Range} from '../ast_host'; -import {assert} from '../utils'; +import {assert, AstHost, FatalLinkerError, Range} from '../../../../linker'; /** * This implementation of `AstHost` is able to get information from Babel AST nodes. diff --git a/packages/compiler-cli/linker/babel/src/babel_declaration_scope.ts b/packages/compiler-cli/linker/babel/src/babel_declaration_scope.ts new file mode 100644 index 0000000000..5eb9d0cee3 --- /dev/null +++ b/packages/compiler-cli/linker/babel/src/babel_declaration_scope.ts @@ -0,0 +1,67 @@ +/** + * @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 {NodePath, Scope} from '@babel/traverse'; +import * as t from '@babel/types'; + +import {DeclarationScope} from '../../../linker'; + +export type ConstantScopePath = NodePath; + +/** + * This class represents the lexical scope of a partial declaration in Babel source code. + * + * Its only responsibility is to compute a reference object for the scope of shared constant + * statements that will be generated during partial linking. + */ +export class BabelDeclarationScope implements DeclarationScope { + /** + * Construct a new `BabelDeclarationScope`. + * + * @param declarationScope the Babel scope containing the declaration call expression. + */ + constructor(private declarationScope: Scope) {} + + /** + * Compute the Babel `NodePath` that can be used to reference the lexical scope where any + * shared constant statements would be inserted. + * + * There will only be a shared constant scope if the expression is in an ECMAScript module, or a + * UMD module. Otherwise `null` is returned to indicate that constant statements must be emitted + * locally to the generated linked definition, to avoid polluting the global scope. + * + * @param expression the expression that points to the Angular core framework import. + */ + getConstantScopeRef(expression: t.Expression): ConstantScopePath|null { + // If the expression is of the form `a.b.c` then we want to get the far LHS (e.g. `a`). + let bindingExpression = expression; + while (t.isMemberExpression(bindingExpression)) { + bindingExpression = bindingExpression.object; + } + + if (!t.isIdentifier(bindingExpression)) { + return null; + } + + // The binding of the expression is where this identifier was declared. + // This could be a variable declaration, an import namespace or a function parameter. + const binding = this.declarationScope.getBinding(bindingExpression.name); + if (binding === undefined) { + return null; + } + + // We only support shared constant statements if the binding was in a UMD module (i.e. declared + // within a `t.Function`) or an ECMASCript module (i.e. declared at the top level of a + // `t.Program` that is marked as a module). + const path = binding.scope.path; + if (!path.isFunctionParent() && !(path.isProgram() && path.node.sourceType === 'module')) { + return null; + } + + return path; + } +} diff --git a/packages/compiler-cli/linker/babel/src/es2015_linker_plugin.ts b/packages/compiler-cli/linker/babel/src/es2015_linker_plugin.ts new file mode 100644 index 0000000000..b13be8e407 --- /dev/null +++ b/packages/compiler-cli/linker/babel/src/es2015_linker_plugin.ts @@ -0,0 +1,167 @@ +/** + * @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 {PluginObj} from '@babel/core'; +import {NodePath} from '@babel/traverse'; +import * as t from '@babel/types'; + +import {FileLinker, isFatalLinkerError, LinkerEnvironment, LinkerOptions} from '../../../linker'; + +import {BabelAstFactory} from './ast/babel_ast_factory'; +import {BabelAstHost} from './ast/babel_ast_host'; +import {BabelDeclarationScope, ConstantScopePath} from './babel_declaration_scope'; + +/** + * Create a Babel plugin that visits the program, identifying and linking partial declarations. + * + * The plugin delegates most of its work to a generic `FileLinker` for each file (`t.Program` in + * Babel) that is visited. + */ +export function createEs2015LinkerPlugin(options: Partial = {}): PluginObj { + let fileLinker: FileLinker|null = null; + + const linkerEnvironment = LinkerEnvironment.create( + new BabelAstHost(), new BabelAstFactory(), options); + + return { + visitor: { + Program: { + + /** + * Create a new `FileLinker` as we enter each file (`t.Program` in Babel). + */ + enter(path: NodePath): void { + assertNull(fileLinker); + const file: BabelFile = path.hub.file; + fileLinker = new FileLinker(linkerEnvironment, file.opts.filename ?? '', file.code); + }, + + /** + * On exiting the file, insert any shared constant statements that were generated during + * linking of the partial declarations. + */ + exit(): void { + assertNotNull(fileLinker); + for (const {constantScope, statements} of fileLinker.getConstantStatements()) { + insertStatements(constantScope, statements); + } + fileLinker = null; + } + }, + + /** + * Test each call expression to see if it is a partial declaration; it if is then replace it + * with the results of linking the declaration. + */ + CallExpression(call: NodePath): void { + try { + assertNotNull(fileLinker); + + const callee = call.node.callee; + if (!t.isExpression(callee)) { + return; + } + const calleeName = linkerEnvironment.host.getSymbolName(callee); + if (calleeName === null) { + return; + } + const args = call.node.arguments; + if (!fileLinker.isPartialDeclaration(calleeName) || !isExpressionArray(args)) { + return; + } + + const declarationScope = new BabelDeclarationScope(call.scope); + const replacement = fileLinker.linkPartialDeclaration(calleeName, args, declarationScope); + + call.replaceWith(replacement); + } catch (e) { + const node = isFatalLinkerError(e) ? e.node as t.Node : call.node; + throw buildCodeFrameError(call.hub.file, e.message, node); + } + } + } + }; +} + +/** + * Insert the `statements` at the location defined by `path`. + * + * The actual insertion strategy depends upon the type of the `path`. + */ +function insertStatements(path: ConstantScopePath, statements: t.Statement[]): void { + if (path.isFunction()) { + insertIntoFunction(path, statements); + } else if (path.isProgram()) { + insertIntoProgram(path, statements); + } +} + +/** + * Insert the `statements` at the top of the body of the `fn` function. + */ +function insertIntoFunction(fn: NodePath, statements: t.Statement[]): void { + const body = fn.get('body'); + body.unshiftContainer('body', statements); +} + +/** + * Insert the `statements` at the top of the `program`, below any import statements. + */ +function insertIntoProgram(program: NodePath, statements: t.Statement[]): void { + const body = program.get('body'); + const importStatements = body.filter(statement => statement.isImportDeclaration()); + if (importStatements.length === 0) { + program.unshiftContainer('body', statements); + } else { + importStatements[importStatements.length - 1].insertAfter(statements); + } +} + +/** + * Return true if all the `nodes` are Babel expressions. + */ +function isExpressionArray(nodes: t.Node[]): nodes is t.Expression[] { + return nodes.every(node => t.isExpression(node)); +} + +/** + * Assert that the given `obj` is `null`. + */ +function assertNull(obj: T|null): asserts obj is null { + if (obj !== null) { + throw new Error('BUG - expected `obj` to be null'); + } +} + +/** + * Assert that the given `obj` is not `null`. + */ +function assertNotNull(obj: T|null): asserts obj is T { + if (obj === null) { + throw new Error('BUG - expected `obj` not to be null'); + } +} + +/** + * Create a string representation of an error that includes the code frame of the `node`. + */ +function buildCodeFrameError(file: BabelFile, message: string, node: t.Node): string { + const filename = file.opts.filename || '(unknown file)'; + const error = file.buildCodeFrameError(node, message); + return `${filename}: ${error.message}`; +} + +/** + * This interface is making up for the fact that the Babel typings for `NodePath.hub.file` are + * lacking. + */ +interface BabelFile { + code: string; + opts: {filename?: string;}; + + buildCodeFrameError(node: t.Node, message: string): Error; +} diff --git a/packages/compiler-cli/linker/babel/test/BUILD.bazel b/packages/compiler-cli/linker/babel/test/BUILD.bazel new file mode 100644 index 0000000000..f2a426de88 --- /dev/null +++ b/packages/compiler-cli/linker/babel/test/BUILD.bazel @@ -0,0 +1,36 @@ +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", + "//packages/compiler-cli/linker/babel", + "//packages/compiler-cli/src/ngtsc/translator", + "@npm//@babel/core", + "@npm//@babel/generator", + "@npm//@babel/parser", + "@npm//@babel/template", + "@npm//@babel/traverse", + "@npm//@babel/types", + "@npm//@types/babel__core", + "@npm//@types/babel__generator", + "@npm//@types/babel__template", + "@npm//@types/babel__traverse", + ], +) + +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/babel/test/ast/babel_ast_factory_spec.ts similarity index 99% rename from packages/compiler-cli/linker/test/ast/babel/babel_ast_factory_spec.ts rename to packages/compiler-cli/linker/babel/test/ast/babel_ast_factory_spec.ts index 106e2e70d2..c1f874d7f6 100644 --- a/packages/compiler-cli/linker/test/ast/babel/babel_ast_factory_spec.ts +++ b/packages/compiler-cli/linker/babel/test/ast/babel_ast_factory_spec.ts @@ -10,7 +10,7 @@ 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'; +import {BabelAstFactory} from '../../src/ast/babel_ast_factory'; describe('BabelAstFactory', () => { let factory: BabelAstFactory; diff --git a/packages/compiler-cli/linker/test/ast/babel/babel_ast_host_spec.ts b/packages/compiler-cli/linker/babel/test/ast/babel_ast_host_spec.ts similarity index 99% rename from packages/compiler-cli/linker/test/ast/babel/babel_ast_host_spec.ts rename to packages/compiler-cli/linker/babel/test/ast/babel_ast_host_spec.ts index 2c371c10bf..746f6cd3ae 100644 --- a/packages/compiler-cli/linker/test/ast/babel/babel_ast_host_spec.ts +++ b/packages/compiler-cli/linker/babel/test/ast/babel_ast_host_spec.ts @@ -8,7 +8,7 @@ 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'; +import {BabelAstHost} from '../../src/ast/babel_ast_host'; describe('BabelAstHost', () => { let host: BabelAstHost; diff --git a/packages/compiler-cli/linker/babel/test/babel_declaration_scope_spec.ts b/packages/compiler-cli/linker/babel/test/babel_declaration_scope_spec.ts new file mode 100644 index 0000000000..b4c5545f61 --- /dev/null +++ b/packages/compiler-cli/linker/babel/test/babel_declaration_scope_spec.ts @@ -0,0 +1,116 @@ +/** + * @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 {parse} from '@babel/parser'; +import traverse, {NodePath} from '@babel/traverse'; +import * as t from '@babel/types'; +import {BabelDeclarationScope} from '../src/babel_declaration_scope'; + + +describe('BabelDeclarationScope', () => { + describe('getConstantScopeRef()', () => { + it('should return a path to the ES module where the expression was imported', () => { + const ast = parse( + [ + 'import * as core from \'@angular/core\';', + 'function foo() {', + ' var TEST = core;', + '}', + ].join('\n'), + {sourceType: 'module'}); + const nodePath = findVarDeclaration(ast, 'TEST'); + const scope = new BabelDeclarationScope(nodePath.scope); + const constantScope = scope.getConstantScopeRef(nodePath.get('init').node); + expect(constantScope).not.toBe(null); + expect(constantScope!.node).toBe(ast.program); + }); + + it('should return a path to the ES Module where the expression is declared', () => { + const ast = parse( + [ + 'var core;', + 'export function foo() {', + ' var TEST = core;', + '}', + ].join('\n'), + {sourceType: 'module'}); + const nodePath = findVarDeclaration(ast, 'TEST'); + const scope = new BabelDeclarationScope(nodePath.scope); + const constantScope = scope.getConstantScopeRef(nodePath.get('init').node); + expect(constantScope).not.toBe(null); + expect(constantScope!.node).toBe(ast.program); + }); + + it('should return null if the file is not an ES module', () => { + const ast = parse( + [ + 'var core;', + 'function foo() {', + ' var TEST = core;', + '}', + ].join('\n'), + {sourceType: 'script'}); + const nodePath = findVarDeclaration(ast, 'TEST'); + const scope = new BabelDeclarationScope(nodePath.scope); + const constantScope = scope.getConstantScopeRef(nodePath.get('init').node); + expect(constantScope).toBe(null); + }); + + it('should return the IIFE factory function where the expression is a parameter', () => { + const ast = parse( + [ + 'var core;', + '(function(core) {', + ' var BLOCK = \'block\';', + ' function foo() {', + ' var TEST = core;', + ' }', + '})(core);', + ].join('\n'), + {sourceType: 'script'}); + const nodePath = findVarDeclaration(ast, 'TEST'); + const fnPath = findFirstFunction(ast); + const scope = new BabelDeclarationScope(nodePath.scope); + const constantScope = scope.getConstantScopeRef(nodePath.get('init').node); + expect(constantScope).not.toBe(null); + expect(constantScope!.isFunction()).toBe(true); + expect(constantScope!.node).toEqual(fnPath.node); + }); + }); +}); + +function findVarDeclaration( + file: t.File, varName: string): NodePath { + let varDecl: NodePath|undefined = undefined; + traverse(file, { + VariableDeclarator: (path) => { + const id = path.get('id'); + if (id.isIdentifier() && id.node.name === varName && path.get('init') !== null) { + varDecl = path; + path.stop(); + } + } + }); + if (varDecl === undefined) { + throw new Error(`TEST BUG: expected to find variable declaration for ${varName}.`); + } + return varDecl; +} + +function findFirstFunction(file: t.File): NodePath { + let fn: NodePath|undefined = undefined; + traverse(file, { + Function: (path) => { + fn = path; + path.stop(); + } + }); + if (fn === undefined) { + throw new Error(`TEST BUG: expected to find a function.`); + } + return fn; +} diff --git a/packages/compiler-cli/linker/babel/test/es2015_linker_plugin_spec.ts b/packages/compiler-cli/linker/babel/test/es2015_linker_plugin_spec.ts new file mode 100644 index 0000000000..1f492df846 --- /dev/null +++ b/packages/compiler-cli/linker/babel/test/es2015_linker_plugin_spec.ts @@ -0,0 +1,256 @@ +/** + * @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 o from '@angular/compiler/src/output/output_ast'; +import {NodePath, PluginObj, transformSync} from '@babel/core'; +import generate from '@babel/generator'; +import * as t from '@babel/types'; + +import {FileLinker} from '../../../linker'; +import {PartialDirectiveLinkerVersion1} from '../../src/file_linker/partial_linkers/partial_directive_linker_1'; +import {createEs2015LinkerPlugin} from '../src/es2015_linker_plugin'; + +describe('createEs2015LinkerPlugin()', () => { + it('should return a Babel plugin visitor that handles Program (enter/exit) and CallExpression nodes', + () => { + const plugin = createEs2015LinkerPlugin(); + expect(plugin.visitor).toEqual({ + Program: { + enter: jasmine.any(Function), + exit: jasmine.any(Function), + }, + CallExpression: jasmine.any(Function), + }); + }); + + it('should return a Babel plugin that calls FileLinker.isPartialDeclaration() on each call expression', + () => { + const isPartialDeclarationSpy = spyOn(FileLinker.prototype, 'isPartialDeclaration'); + + transformSync( + [ + 'var core;', `fn1()`, 'fn2({prop: () => fn3({})});', `x.method(() => fn4());`, + 'spread(...x);' + ].join('\n'), + { + plugins: [createEs2015LinkerPlugin()], + filename: '/test.js', + parserOpts: {sourceType: 'unambiguous'}, + }); + expect(isPartialDeclarationSpy.calls.allArgs()).toEqual([ + ['fn1'], + ['fn2'], + ['fn3'], + ['method'], + ['fn4'], + ['spread'], + ]); + }); + + it('should return a Babel plugin that calls FileLinker.linkPartialDeclaration() on each matching declaration', + () => { + const linkSpy = spyOn(FileLinker.prototype, 'linkPartialDeclaration') + .and.returnValue(t.identifier('REPLACEMENT')); + + transformSync( + [ + 'var core;', + `$ngDeclareDirective({version: 1, ngImport: core, x: 1});`, + `$ngDeclareComponent({version: 1, ngImport: core, foo: () => $ngDeclareDirective({version: 1, ngImport: core, x: 2})});`, + `x.qux(() => $ngDeclareDirective({version: 1, ngImport: core, x: 3}));`, + 'spread(...x);', + ].join('\n'), + { + plugins: [createEs2015LinkerPlugin()], + filename: '/test.js', + parserOpts: {sourceType: 'unambiguous'}, + }); + + expect(humanizeLinkerCalls(linkSpy.calls)).toEqual([ + ['$ngDeclareDirective', '{version:1,ngImport:core,x:1}'], + [ + '$ngDeclareComponent', + '{version:1,ngImport:core,foo:()=>$ngDeclareDirective({version:1,ngImport:core,x:2})}' + ], + // Note we do not process `x:2` declaration since it is nested within another declaration + ['$ngDeclareDirective', '{version:1,ngImport:core,x:3}'] + ]); + }); + + it('should return a Babel plugin that replaces call expressions with the return value from FileLinker.linkPartialDeclaration()', + () => { + let replaceCount = 0; + spyOn(FileLinker.prototype, 'linkPartialDeclaration') + .and.callFake(() => t.identifier('REPLACEMENT_' + ++replaceCount)); + const result = transformSync( + [ + 'var core;', + '$ngDeclareDirective({version: 1, ngImport: core});', + '$ngDeclareDirective({version: 1, ngImport: core, foo: () => bar({})});', + 'x.qux();', + 'spread(...x);', + ].join('\n'), + { + plugins: [createEs2015LinkerPlugin()], + filename: '/test.js', + parserOpts: {sourceType: 'unambiguous'}, + generatorOpts: {compact: true}, + }); + expect(result!.code).toEqual('var core;REPLACEMENT_1;REPLACEMENT_2;x.qux();spread(...x);'); + }); + + it('should return a Babel plugin that adds shared statements after any imports', () => { + spyOnLinkPartialDeclarationWithConstants(o.literal('REPLACEMENT')); + const result = transformSync( + [ + 'import * as core from \'some-module\';', + 'import {id} from \'other-module\';', + `$ngDeclareDirective({version: 1, ngImport: core})`, + `$ngDeclareDirective({version: 1, ngImport: core})`, + `$ngDeclareDirective({version: 1, ngImport: core})`, + ].join('\n'), + { + plugins: [createEs2015LinkerPlugin()], + filename: '/test.js', + parserOpts: {sourceType: 'unambiguous'}, + generatorOpts: {compact: true}, + }); + expect(result!.code) + .toEqual( + 'import*as core from\'some-module\';import{id}from\'other-module\';const _c0=[1];const _c1=[2];const _c2=[3];"REPLACEMENT";"REPLACEMENT";"REPLACEMENT";'); + }); + + it('should return a Babel plugin that adds shared statements at the start of the program if it is an ECMAScript Module and there are no imports', + () => { + spyOnLinkPartialDeclarationWithConstants(o.literal('REPLACEMENT')); + const result = transformSync( + [ + 'var core;', + `$ngDeclareDirective({version: 1, ngImport: core})`, + `$ngDeclareDirective({version: 1, ngImport: core})`, + `$ngDeclareDirective({version: 1, ngImport: core})`, + ].join('\n'), + { + plugins: [createEs2015LinkerPlugin()], + filename: '/test.js', + // We declare the file as a module because this cannot be inferred from the source + parserOpts: {sourceType: 'module'}, + generatorOpts: {compact: true}, + }); + expect(result!.code) + .toEqual( + 'const _c0=[1];const _c1=[2];const _c2=[3];var core;"REPLACEMENT";"REPLACEMENT";"REPLACEMENT";'); + }); + + it('should return a Babel plugin that adds shared statements at the start of the function body if the ngImport is from a function parameter', + () => { + spyOnLinkPartialDeclarationWithConstants(o.literal('REPLACEMENT')); + const result = transformSync( + [ + 'function run(core) {', ` $ngDeclareDirective({version: 1, ngImport: core})`, + ` $ngDeclareDirective({version: 1, ngImport: core})`, + ` $ngDeclareDirective({version: 1, ngImport: core})`, '}' + ].join('\n'), + { + plugins: [createEs2015LinkerPlugin()], + filename: '/test.js', + parserOpts: {sourceType: 'unambiguous'}, + generatorOpts: {compact: true}, + }); + expect(result!.code) + .toEqual( + 'function run(core){const _c0=[1];const _c1=[2];const _c2=[3];"REPLACEMENT";"REPLACEMENT";"REPLACEMENT";}'); + }); + + it('should return a Babel plugin that adds shared statements into an IIFE if no scope could not be derived for the ngImport', + () => { + spyOnLinkPartialDeclarationWithConstants(o.literal('REPLACEMENT')); + const result = transformSync( + [ + 'function run() {', + ` $ngDeclareDirective({version: 1, ngImport: core})`, + ` $ngDeclareDirective({version: 1, ngImport: core})`, + ` $ngDeclareDirective({version: 1, ngImport: core})`, + '}', + ].join('\n'), + { + plugins: [createEs2015LinkerPlugin()], + filename: '/test.js', + parserOpts: {sourceType: 'unambiguous'}, + generatorOpts: {compact: true}, + }); + expect(result!.code).toEqual([ + `function run(){`, + `(function(){const _c0=[1];return"REPLACEMENT";})();`, + `(function(){const _c0=[2];return"REPLACEMENT";})();`, + `(function(){const _c0=[3];return"REPLACEMENT";})();`, + `}`, + ].join('')); + }); + + it('should still execute other plugins that match AST nodes inside the result of the replacement', + () => { + spyOnLinkPartialDeclarationWithConstants(o.fn([], [], null, null, 'FOO')); + const result = transformSync( + [ + `$ngDeclareDirective({version: 1, ngImport: core}); FOO;`, + ].join('\n'), + { + plugins: [ + createEs2015LinkerPlugin(), + createIdentifierMapperPlugin('FOO', 'BAR'), + createIdentifierMapperPlugin('_c0', 'x1'), + ], + filename: '/test.js', + parserOpts: {sourceType: 'module'}, + generatorOpts: {compact: true}, + }); + expect(result!.code).toEqual([ + `(function(){const x1=[1];return function BAR(){};})();BAR;`, + ].join('')); + }); +}); + +/** + * Convert the arguments of the spied-on `calls` into a human readable array. + */ +function humanizeLinkerCalls( + calls: jasmine.Calls) { + return calls.all().map(({args: [fn, args]}) => [fn, generate(args[0], {compact: true}).code]); +} + +/** + * Spy on the `PartialDirectiveLinkerVersion1.linkPartialDeclaration()` method, triggering + * shared constants to be created. + */ +function spyOnLinkPartialDeclarationWithConstants(replacement: o.Expression) { + let callCount = 0; + spyOn(PartialDirectiveLinkerVersion1.prototype, 'linkPartialDeclaration') + .and.callFake(((sourceUrl, code, constantPool) => { + const constArray = o.literalArr([o.literal(++callCount)]); + // We have to add the constant twice or it will not create a shared statement + constantPool.getConstLiteral(constArray); + constantPool.getConstLiteral(constArray); + return replacement; + }) as typeof PartialDirectiveLinkerVersion1.prototype.linkPartialDeclaration); +} + +/** + * A simple Babel plugin that will replace all identifiers that match `` with identifiers + * called ``. + */ +function createIdentifierMapperPlugin(src: string, dest: string): PluginObj { + return { + visitor: { + Identifier(path: NodePath) { + if (path.node.name === src) { + path.replaceWith(t.identifier(dest)); + } + } + }, + }; +} diff --git a/packages/compiler-cli/linker/index.ts b/packages/compiler-cli/linker/index.ts index 823e9bf40a..96256b7b43 100644 --- a/packages/compiler-cli/linker/index.ts +++ b/packages/compiler-cli/linker/index.ts @@ -5,3 +5,10 @@ * 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 {AstHost, Range} from './src/ast/ast_host'; +export {assert} from './src/ast/utils'; +export {FatalLinkerError, isFatalLinkerError} from './src/fatal_linker_error'; +export {DeclarationScope} from './src/file_linker/declaration_scope'; +export {FileLinker} from './src/file_linker/file_linker'; +export {LinkerEnvironment} from './src/file_linker/linker_environment'; +export {LinkerOptions} from './src/file_linker/linker_options'; diff --git a/packages/compiler-cli/linker/src/ast/ast_value.ts b/packages/compiler-cli/linker/src/ast/ast_value.ts new file mode 100644 index 0000000000..2275c61aef --- /dev/null +++ b/packages/compiler-cli/linker/src/ast/ast_value.ts @@ -0,0 +1,240 @@ +/** + * @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 o from '@angular/compiler'; +import {FatalLinkerError} from '../fatal_linker_error'; +import {AstHost, Range} from './ast_host'; + +/** + * This helper class wraps an object expression along with an `AstHost` object, exposing helper + * methods that make it easier to extract the properties of the object. + */ +export class AstObject { + /** + * Create a new `AstObject` from the given `expression` and `host`. + */ + static parse(expression: TExpression, host: AstHost): + AstObject { + const obj = host.parseObjectLiteral(expression); + return new AstObject(expression, obj, host); + } + + private constructor( + readonly expression: TExpression, private obj: Map, + private host: AstHost) {} + + /** + * Returns true if the object has a property called `propertyName`. + */ + has(propertyName: string): boolean { + return this.obj.has(propertyName); + } + + /** + * Returns the number value of the property called `propertyName`. + * + * Throws an error if there is no such property or the property is not a number. + */ + getNumber(propertyName: string): number { + return this.host.parseNumericLiteral(this.getRequiredProperty(propertyName)); + } + + /** + * Returns the string value of the property called `propertyName`. + * + * Throws an error if there is no such property or the property is not a string. + */ + getString(propertyName: string): string { + return this.host.parseStringLiteral(this.getRequiredProperty(propertyName)); + } + + /** + * Returns the boolean value of the property called `propertyName`. + * + * Throws an error if there is no such property or the property is not a boolean. + */ + getBoolean(propertyName: string): boolean { + return this.host.parseBooleanLiteral(this.getRequiredProperty(propertyName)); + } + + /** + * Returns the nested `AstObject` parsed from the property called `propertyName`. + * + * Throws an error if there is no such property or the property is not an object. + */ + getObject(propertyName: string): AstObject { + const expr = this.getRequiredProperty(propertyName); + const obj = this.host.parseObjectLiteral(expr); + return new AstObject(expr, obj, this.host); + } + + /** + * Returns an array of `AstValue` objects parsed from the property called `propertyName`. + * + * Throws an error if there is no such property or the property is not an array. + */ + getArray(propertyName: string): AstValue[] { + const arr = this.host.parseArrayLiteral(this.getRequiredProperty(propertyName)); + return arr.map(entry => new AstValue(entry, this.host)); + } + + /** + * Returns a `WrappedNodeExpr` object that wraps the expression at the property called + * `propertyName`. + * + * Throws an error if there is no such property. + */ + getOpaque(propertyName: string): o.WrappedNodeExpr { + return new o.WrappedNodeExpr(this.getRequiredProperty(propertyName)); + } + + /** + * Returns the raw `TExpression` value of the property called `propertyName`. + * + * Throws an error if there is no such property. + */ + getNode(propertyName: string): TExpression { + return this.getRequiredProperty(propertyName); + } + + /** + * Returns an `AstValue` that wraps the value of the property called `propertyName`. + * + * Throws an error if there is no such property. + */ + getValue(propertyName: string): AstValue { + return new AstValue(this.getRequiredProperty(propertyName), this.host); + } + + /** + * Converts the AstObject to a raw JavaScript object, mapping each property value (as an + * `AstValue`) to the generic type (`T`) via the `mapper` function. + */ + toLiteral(mapper: (value: AstValue) => T): {[key: string]: T} { + const result: {[key: string]: T} = {}; + for (const [key, expression] of this.obj) { + result[key] = mapper(new AstValue(expression, this.host)); + } + return result; + } + + private getRequiredProperty(propertyName: string): TExpression { + if (!this.obj.has(propertyName)) { + throw new FatalLinkerError( + this.expression, `Expected property '${propertyName}' to be present.`); + } + return this.obj.get(propertyName)!; + } +} + +/** + * This helper class wraps an `expression`, exposing methods that use the `host` to give + * access to the underlying value of the wrapped expression. + */ +export class AstValue { + constructor(private expression: TExpression, private host: AstHost) {} + + /** + * Is this value a number? + */ + isNumber(): boolean { + return this.host.isNumericLiteral(this.expression); + } + + /** + * Parse the number from this value, or error if it is not a number. + */ + getNumber(): number { + return this.host.parseNumericLiteral(this.expression); + } + + /** + * Is this value a string? + */ + isString(): boolean { + return this.host.isStringLiteral(this.expression); + } + + /** + * Parse the string from this value, or error if it is not a string. + */ + getString(): string { + return this.host.parseStringLiteral(this.expression); + } + + /** + * Is this value a boolean? + */ + isBoolean(): boolean { + return this.host.isBooleanLiteral(this.expression); + } + + /** + * Parse the boolean from this value, or error if it is not a boolean. + */ + getBoolean(): boolean { + return this.host.parseBooleanLiteral(this.expression); + } + + /** + * Is this value an object literal? + */ + isObject(): boolean { + return this.host.isObjectLiteral(this.expression); + } + + /** + * Parse this value into an `AstObject`, or error if it is not an object literal. + */ + getObject(): AstObject { + return AstObject.parse(this.expression, this.host); + } + + /** + * Is this value an array literal? + */ + isArray(): boolean { + return this.host.isArrayLiteral(this.expression); + } + + /** + * Parse this value into an array of `AstValue` objects, or error if it is not an array literal. + */ + getArray(): AstValue[] { + const arr = this.host.parseArrayLiteral(this.expression); + return arr.map(entry => new AstValue(entry, this.host)); + } + + /** + * Is this value a function expression? + */ + isFunction(): boolean { + return this.host.isFunctionExpression(this.expression); + } + + /** + * Extract the return value as an `AstValue` from this value as a function expression, or error if + * it is not a function expression. + */ + getFunctionReturnValue(): AstValue { + return new AstValue(this.host.parseReturnValue(this.expression), this.host); + } + + /** + * Return the `TExpression` of this value wrapped in a `WrappedNodeExpr`. + */ + getOpaque(): o.WrappedNodeExpr { + return new o.WrappedNodeExpr(this.expression); + } + + /** + * Get the range of the location of this value in the original source. + */ + getRange(): Range { + return this.host.getRange(this.expression); + } +} diff --git a/packages/compiler-cli/linker/src/fatal_linker_error.ts b/packages/compiler-cli/linker/src/fatal_linker_error.ts index df494a8929..49bf52f3f3 100644 --- a/packages/compiler-cli/linker/src/fatal_linker_error.ts +++ b/packages/compiler-cli/linker/src/fatal_linker_error.ts @@ -10,7 +10,7 @@ * An unrecoverable error during linking. */ export class FatalLinkerError extends Error { - private readonly type = 'FatalLinkerError'; + readonly type = 'FatalLinkerError'; /** * Create a new FatalLinkerError. diff --git a/packages/compiler-cli/linker/src/file_linker/declaration_scope.ts b/packages/compiler-cli/linker/src/file_linker/declaration_scope.ts new file mode 100644 index 0000000000..a543bc3b87 --- /dev/null +++ b/packages/compiler-cli/linker/src/file_linker/declaration_scope.ts @@ -0,0 +1,45 @@ +/** + * @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 + */ + +/** + * This interface represents the lexical scope of a partial declaration in the source code. + * + * For example, if you had the following code: + * + * ``` + * function foo() { + * function bar () { + * $ngDeclareDirective({...}); + * } + * } + * ``` + * + * The `DeclarationScope` of the `$ngDeclareDirective()` call is the body of the `bar()` function. + * + * The `FileLinker` uses this object to identify the lexical scope of any constant statements that + * might be generated by the linking process (i.e. where the `ConstantPool` lives for a set of + * partial linkers). + */ +export interface DeclarationScope { + /** + * Get a `TSharedConstantScope` object that can be used to reference the lexical scope where any + * shared constant statements would be inserted. + * + * This object is generic because different AST implementations will need different + * `TConstantScope` types to be able to insert shared constant statements. For example in Babel + * this would be a `NodePath` object; in TS it would just be a `Node` object. + * + * If it is not possible to find such a shared scope, then constant statements will be wrapped up + * with their generated linked definition expression, in the form of an IIFE. + * + * @param expression the expression that points to the Angular core framework import. + * @returns a reference to a reference object for where the shared constant statements will be + * inserted, or `null` if it is not possible to have a shared scope. + */ + getConstantScopeRef(expression: TExpression): TSharedConstantScope|null; +} diff --git a/packages/compiler-cli/linker/src/file_linker/emit_scopes/emit_scope.ts b/packages/compiler-cli/linker/src/file_linker/emit_scopes/emit_scope.ts new file mode 100644 index 0000000000..75f47067ce --- /dev/null +++ b/packages/compiler-cli/linker/src/file_linker/emit_scopes/emit_scope.ts @@ -0,0 +1,48 @@ +/** + * @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 {ConstantPool} from '@angular/compiler'; +import * as o from '@angular/compiler/src/output/output_ast'; +import {LinkerImportGenerator} from '../../linker_import_generator'; +import {LinkerEnvironment} from '../linker_environment'; + +/** + * This class represents (from the point of view of the `FileLinker`) the scope in which + * statements and expressions related to a linked partial declaration will be emitted. + * + * It holds a copy of a `ConstantPool` that is used to capture any constant statements that need to + * be emitted in this context. + * + * This implementation will emit the definition and the constant statements separately. + */ +export class EmitScope { + readonly constantPool = new ConstantPool(); + + constructor( + protected readonly ngImport: TExpression, + protected readonly linkerEnvironment: LinkerEnvironment) {} + + /** + * Translate the given Output AST definition expression into a generic `TExpression`. + * + * Use a `LinkerImportGenerator` to handle any imports in the definition. + */ + translateDefinition(definition: o.Expression): TExpression { + return this.linkerEnvironment.translator.translateExpression( + definition, new LinkerImportGenerator(this.ngImport)); + } + + /** + * Return any constant statements that are shared between all uses of this `EmitScope`. + */ + getConstantStatements(): TStatement[] { + const {translator} = this.linkerEnvironment; + const importGenerator = new LinkerImportGenerator(this.ngImport); + return this.constantPool.statements.map( + statement => translator.translateStatement(statement, importGenerator)); + } +} diff --git a/packages/compiler-cli/linker/src/file_linker/emit_scopes/iife_emit_scope.ts b/packages/compiler-cli/linker/src/file_linker/emit_scopes/iife_emit_scope.ts new file mode 100644 index 0000000000..18d0982d2d --- /dev/null +++ b/packages/compiler-cli/linker/src/file_linker/emit_scopes/iife_emit_scope.ts @@ -0,0 +1,40 @@ +/** + * @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 o from '@angular/compiler/src/output/output_ast'; +import {EmitScope} from './emit_scope'; + +/** + * This class is a specialization of the `EmitScope` class that is designed for the situation where + * there is no clear shared scope for constant statements. In this case they are bundled with the + * translated definition inside an IIFE. + */ +export class IifeEmitScope extends EmitScope { + /** + * Translate the given Output AST definition expression into a generic `TExpression`. + * + * Wraps the output from `EmitScope.translateDefinition()` and `EmitScope.getConstantStatements()` + * in an IIFE. + */ + translateDefinition(definition: o.Expression): TExpression { + const {factory} = this.linkerEnvironment; + const constantStatements = super.getConstantStatements(); + + const returnStatement = factory.createReturnStatement(super.translateDefinition(definition)); + const body = factory.createBlock([...constantStatements, returnStatement]); + const fn = factory.createFunctionExpression(/* name */ null, /* args */[], body); + return factory.createCallExpression(fn, /* args */[], /* pure */ false); + } + + /** + * It is not valid to call this method, since there will be no shared constant statements - they + * are already emitted in the IIFE alongside the translated definition. + */ + getConstantStatements(): TStatement[] { + throw new Error('BUG - IifeEmitScope should not expose any constant statements'); + } +} diff --git a/packages/compiler-cli/linker/src/file_linker/file_linker.ts b/packages/compiler-cli/linker/src/file_linker/file_linker.ts new file mode 100644 index 0000000000..2b541fa372 --- /dev/null +++ b/packages/compiler-cli/linker/src/file_linker/file_linker.ts @@ -0,0 +1,94 @@ +/** + * @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 {AstObject} from '../ast/ast_value'; +import {DeclarationScope} from './declaration_scope'; +import {EmitScope} from './emit_scopes/emit_scope'; +import {IifeEmitScope} from './emit_scopes/iife_emit_scope'; +import {LinkerEnvironment} from './linker_environment'; +import {PartialLinkerSelector} from './partial_linkers/partial_linker_selector'; + +export const NO_STATEMENTS: Readonly = [] as const; + +/** + * This class is responsible for linking all the partial declarations found in a single file. + */ +export class FileLinker { + private linkerSelector = new PartialLinkerSelector(); + private emitScopes = new Map>(); + + constructor( + private linkerEnvironment: LinkerEnvironment, + private sourceUrl: string, readonly code: string) {} + + /** + * Return true if the given callee name matches a partial declaration that can be linked. + */ + isPartialDeclaration(calleeName: string): boolean { + return this.linkerSelector.supportsDeclaration(calleeName); + } + + /** + * Link the metadata extracted from the args of a call to a partial declaration function. + * + * The `declarationScope` is used to determine the scope and strategy of emission of the linked + * definition and any shared constant statements. + * + * @param declarationFn the name of the function used to declare the partial declaration - e.g. + * `$ngDeclareDirective`. + * @param args the arguments passed to the declaration function. + * @param declarationScope the scope that contains this call to the declaration function. + */ + linkPartialDeclaration( + declarationFn: string, args: TExpression[], + declarationScope: DeclarationScope): TExpression { + if (args.length !== 1) { + throw new Error( + `Invalid function call: It should have only a single object literal argument, but contained ${ + args.length}.`); + } + + const metaObj = AstObject.parse(args[0], this.linkerEnvironment.host); + const ngImport = metaObj.getNode('ngImport'); + const emitScope = this.getEmitScope(ngImport, declarationScope); + + const version = metaObj.getNumber('version'); + const linker = this.linkerSelector.getLinker(declarationFn, version); + const definition = + linker.linkPartialDeclaration(this.sourceUrl, this.code, emitScope.constantPool, metaObj); + + return emitScope.translateDefinition(definition); + } + + /** + * Return all the shared constant statements and their associated constant scope references, so + * that they can be inserted into the source code. + */ + getConstantStatements(): {constantScope: TConstantScope, statements: TStatement[]}[] { + const results: {constantScope: TConstantScope, statements: TStatement[]}[] = []; + for (const [constantScope, emitScope] of this.emitScopes.entries()) { + const statements = emitScope.getConstantStatements(); + results.push({constantScope, statements}); + } + return results; + } + + private getEmitScope( + ngImport: TExpression, declarationScope: DeclarationScope): + EmitScope { + const constantScope = declarationScope.getConstantScopeRef(ngImport); + if (constantScope === null) { + // There is no constant scope so we will emit extra statements into the definition IIFE. + return new IifeEmitScope(ngImport, this.linkerEnvironment); + } + + if (!this.emitScopes.has(constantScope)) { + this.emitScopes.set(constantScope, new EmitScope(ngImport, this.linkerEnvironment)); + } + return this.emitScopes.get(constantScope)!; + } +} diff --git a/packages/compiler-cli/linker/src/file_linker/linker_environment.ts b/packages/compiler-cli/linker/src/file_linker/linker_environment.ts new file mode 100644 index 0000000000..c8024dc8a1 --- /dev/null +++ b/packages/compiler-cli/linker/src/file_linker/linker_environment.ts @@ -0,0 +1,25 @@ +/** + * @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 {AstFactory} from '@angular/compiler-cli/src/ngtsc/translator'; + +import {AstHost} from '../ast/ast_host'; +import {DEFAULT_LINKER_OPTIONS, LinkerOptions} from './linker_options'; +import {Translator} from './translator'; + +export class LinkerEnvironment { + readonly translator = new Translator(this.factory); + private constructor( + readonly host: AstHost, readonly factory: AstFactory, + readonly options: LinkerOptions) {} + + static create( + host: AstHost, factory: AstFactory, + options: Partial): LinkerEnvironment { + return new LinkerEnvironment(host, factory, {...DEFAULT_LINKER_OPTIONS, ...options}); + } +} diff --git a/packages/compiler-cli/linker/src/file_linker/linker_options.ts b/packages/compiler-cli/linker/src/file_linker/linker_options.ts new file mode 100644 index 0000000000..d76de5b577 --- /dev/null +++ b/packages/compiler-cli/linker/src/file_linker/linker_options.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 + */ + +/** + * Options to configure the linking behavior. + */ +export interface LinkerOptions { + /** + * Whether to generate legacy i18n message ids. + * The default is `true`. + */ + enableI18nLegacyMessageIdFormat: boolean; + /** + * Whether to convert all line-endings in ICU expressions to `\n` characters. + * The default is `false`. + */ + i18nNormalizeLineEndingsInICUs: boolean; +} + +/** + * The default linker options to use if properties are not provided. + */ +export const DEFAULT_LINKER_OPTIONS: LinkerOptions = { + enableI18nLegacyMessageIdFormat: true, + i18nNormalizeLineEndingsInICUs: false, +}; diff --git a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_component_linker_1.ts b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_component_linker_1.ts new file mode 100644 index 0000000000..1c7bcde242 --- /dev/null +++ b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_component_linker_1.ts @@ -0,0 +1,25 @@ +/** + * @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 {ConstantPool} from '@angular/compiler'; +import * as o from '@angular/compiler/src/output/output_ast'; + +import {AstObject} from '../../ast/ast_value'; + +import {PartialLinker} from './partial_linker'; + +/** + * A `PartialLinker` that is designed to process `$ngDeclareComponent()` call expressions. + */ +export class PartialComponentLinkerVersion1 implements + PartialLinker { + linkPartialDeclaration( + sourceUrl: string, code: string, constantPool: ConstantPool, + metaObj: AstObject): o.Expression { + throw new Error('Not implemented.'); + } +} diff --git a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_directive_linker_1.ts b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_directive_linker_1.ts new file mode 100644 index 0000000000..bd3e8b80a9 --- /dev/null +++ b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_directive_linker_1.ts @@ -0,0 +1,25 @@ +/** + * @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 {ConstantPool} from '@angular/compiler'; +import * as o from '@angular/compiler/src/output/output_ast'; + +import {AstObject} from '../../ast/ast_value'; + +import {PartialLinker} from './partial_linker'; + +/** + * A `PartialLinker` that is designed to process `$ngDeclareDirective()` call expressions. + */ +export class PartialDirectiveLinkerVersion1 implements + PartialLinker { + linkPartialDeclaration( + sourceUrl: string, code: string, constantPool: ConstantPool, + metaObj: AstObject): o.Expression { + throw new Error('Not implemented.'); + } +} diff --git a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_linker.ts b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_linker.ts new file mode 100644 index 0000000000..cc5031f165 --- /dev/null +++ b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_linker.ts @@ -0,0 +1,22 @@ +/** + * @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 {ConstantPool} from '@angular/compiler'; +import * as o from '@angular/compiler/src/output/output_ast'; +import {AstObject} from '../../ast/ast_value'; + +/** + * An interface for classes that can link partial declarations into full definitions. + */ +export interface PartialLinker { + /** + * Link the partial declaration `metaObj` information to generate a full definition expression. + */ + linkPartialDeclaration( + sourceUrl: string, code: string, constantPool: ConstantPool, + metaObj: AstObject): o.Expression; +} diff --git a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_linker_selector.ts b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_linker_selector.ts new file mode 100644 index 0000000000..ba6ffe6aa2 --- /dev/null +++ b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_linker_selector.ts @@ -0,0 +1,44 @@ +/** + * @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 {PartialComponentLinkerVersion1} from './partial_component_linker_1'; +import {PartialDirectiveLinkerVersion1} from './partial_directive_linker_1'; +import {PartialLinker} from './partial_linker'; + +export class PartialLinkerSelector { + private linkers: Record>> = { + '$ngDeclareDirective': { + 1: new PartialDirectiveLinkerVersion1(), + }, + '$ngDeclareComponent': { + 1: new PartialComponentLinkerVersion1(), + }, + }; + + /** + * Returns true if there are `PartialLinker` classes that can handle functions with this name. + */ + supportsDeclaration(functionName: string): boolean { + return this.linkers[functionName] !== undefined; + } + + /** + * Returns the `PartialLinker` that can handle functions with the given name and version. + * Throws an error if there is none. + */ + getLinker(functionName: string, version: number): PartialLinker { + const versions = this.linkers[functionName]; + if (versions === undefined) { + throw new Error(`Unknown partial declaration function ${functionName}.`); + } + const linker = versions[version]; + if (linker === undefined) { + throw new Error(`Unsupported partial declaration version ${version} for ${functionName}.`); + } + return linker; + } +} diff --git a/packages/compiler-cli/linker/src/file_linker/translator.ts b/packages/compiler-cli/linker/src/file_linker/translator.ts new file mode 100644 index 0000000000..7e55f297dc --- /dev/null +++ b/packages/compiler-cli/linker/src/file_linker/translator.ts @@ -0,0 +1,39 @@ +/** + * @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 o from '@angular/compiler'; +import {AstFactory, Context, ExpressionTranslatorVisitor, ImportGenerator, TranslatorOptions} from '@angular/compiler-cli/src/ngtsc/translator'; + +/** + * Generic translator helper class, which exposes methods for translating expressions and + * statements. + */ +export class Translator { + constructor(private factory: AstFactory) {} + + /** + * Translate the given output AST in the context of an expression. + */ + translateExpression( + expression: o.Expression, imports: ImportGenerator, + options: TranslatorOptions = {}): TExpression { + return expression.visitExpression( + new ExpressionTranslatorVisitor(this.factory, imports, options), + new Context(false)); + } + + /** + * Translate the given output AST in the context of a statement. + */ + translateStatement( + statement: o.Statement, imports: ImportGenerator, + options: TranslatorOptions = {}): TStatement { + return statement.visitStatement( + new ExpressionTranslatorVisitor(this.factory, imports, options), + new Context(true)); + } +} diff --git a/packages/compiler-cli/linker/src/linker_import_generator.ts b/packages/compiler-cli/linker/src/linker_import_generator.ts index 00d3bfbc33..d3c4b92325 100644 --- a/packages/compiler-cli/linker/src/linker_import_generator.ts +++ b/packages/compiler-cli/linker/src/linker_import_generator.ts @@ -5,8 +5,8 @@ * 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'; +import {FatalLinkerError} from './fatal_linker_error'; /** * A class that is used to generate imports when translating from Angular Output AST to an AST to @@ -31,7 +31,8 @@ export class LinkerImportGenerator implements ImportGenerator { + describe('has()', () => { + it('should return true if the property exists on the object', () => { + expect(obj.has('a')).toBe(true); + expect(obj.has('b')).toBe(true); + expect(obj.has('z')).toBe(false); + }); + }); + + describe('getNumber()', () => { + it('should return the number value of the property', () => { + expect(obj.getNumber('a')).toEqual(42); + }); + + it('should throw an error if the property is not a number', () => { + expect(() => obj.getNumber('b')) + .toThrowError('Unsupported syntax, expected a numeric literal.'); + }); + }); + + describe('getString()', () => { + it('should return the string value of the property', () => { + expect(obj.getString('b')).toEqual('X'); + }); + + it('should throw an error if the property is not a string', () => { + expect(() => obj.getString('a')) + .toThrowError('Unsupported syntax, expected a string literal.'); + }); + }); + + describe('getBoolean()', () => { + it('should return the boolean value of the property', () => { + expect(obj.getBoolean('c')).toEqual(true); + }); + + it('should throw an error if the property is not a boolean', () => { + expect(() => obj.getBoolean('b')) + .toThrowError('Unsupported syntax, expected a boolean literal.'); + }); + }); + + describe('getObject()', () => { + it('should return an AstObject instance parsed from the value of the property', () => { + expect(obj.getObject('d')).toEqual(AstObject.parse(nestedObj, host)); + }); + + it('should throw an error if the property is not an object expression', () => { + expect(() => obj.getObject('b')) + .toThrowError('Unsupported syntax, expected an object literal.'); + }); + }); + + describe('getArray()', () => { + it('should return an array of AstValue instances of parsed from the value of the property', + () => { + expect(obj.getArray('e')).toEqual([ + new AstValue(factory.createLiteral(1), host), + new AstValue(factory.createLiteral(2), host) + ]); + }); + + it('should throw an error if the property is not an array of expressions', () => { + expect(() => obj.getArray('b')) + .toThrowError('Unsupported syntax, expected an array literal.'); + }); + }); + + describe('getOpaque()', () => { + it('should return the expression value of the property wrapped in a `WrappedNodeExpr`', () => { + expect(obj.getOpaque('d')).toEqual(jasmine.any(WrappedNodeExpr)); + expect(obj.getOpaque('d').node).toEqual(obj.getNode('d')); + }); + + it('should throw an error if the property does not exist', () => { + expect(() => obj.getOpaque('x')).toThrowError('Expected property \'x\' to be present.'); + }); + }); + + describe('getNode()', () => { + it('should return the original expression value of the property', () => { + expect(obj.getNode('a')).toEqual(factory.createLiteral(42)); + }); + + it('should throw an error if the property does not exist', () => { + expect(() => obj.getNode('x')).toThrowError('Expected property \'x\' to be present.'); + }); + }); + + describe('getValue()', () => { + it('should return the expression value of the property wrapped in an `AstValue`', () => { + expect(obj.getValue('a')).toEqual(jasmine.any(AstValue)); + expect(obj.getValue('a').getNumber()).toEqual(42); + }); + + it('should throw an error if the property does not exist', () => { + expect(() => obj.getValue('x')).toThrowError('Expected property \'x\' to be present.'); + }); + }); + + describe('toLiteral()', () => { + it('should convert the AstObject to a raw object with each property mapped', () => { + expect(obj.toLiteral(value => value.getOpaque())).toEqual({ + a: obj.getOpaque('a'), + b: obj.getOpaque('b'), + c: obj.getOpaque('c'), + d: obj.getOpaque('d'), + e: obj.getOpaque('e'), + }); + }); + }); +}); + +describe('AstValue', () => { + describe('isNumber', () => { + it('should return true if the value is a number', () => { + expect(new AstValue(factory.createLiteral(42), host).isNumber()).toEqual(true); + }); + + it('should return false if the value is not a number', () => { + expect(new AstValue(factory.createLiteral('a'), host).isNumber()).toEqual(false); + }); + }); + + describe('getNumber', () => { + it('should return the number value of the AstValue', () => { + expect(new AstValue(factory.createLiteral(42), host).getNumber()).toEqual(42); + }); + + it('should throw an error if the property is not a number', () => { + expect(() => new AstValue(factory.createLiteral('a'), host).getNumber()) + .toThrowError('Unsupported syntax, expected a numeric literal.'); + }); + }); + + describe('isString', () => { + it('should return true if the value is a string', () => { + expect(new AstValue(factory.createLiteral('a'), host).isString()).toEqual(true); + }); + + it('should return false if the value is not a string', () => { + expect(new AstValue(factory.createLiteral(42), host).isString()).toEqual(false); + }); + }); + + describe('getString', () => { + it('should return the string value of the AstValue', () => { + expect(new AstValue(factory.createLiteral('X'), host).getString()).toEqual('X'); + }); + + it('should throw an error if the property is not a string', () => { + expect(() => new AstValue(factory.createLiteral(42), host).getString()) + .toThrowError('Unsupported syntax, expected a string literal.'); + }); + }); + + describe('isBoolean', () => { + it('should return true if the value is a boolean', () => { + expect(new AstValue(factory.createLiteral(true), host).isBoolean()).toEqual(true); + }); + + it('should return false if the value is not a boolean', () => { + expect(new AstValue(factory.createLiteral(42), host).isBoolean()).toEqual(false); + }); + }); + + describe('getBoolean', () => { + it('should return the boolean value of the AstValue', () => { + expect(new AstValue(factory.createLiteral(true), host).getBoolean()).toEqual(true); + }); + + it('should throw an error if the property is not a boolean', () => { + expect(() => new AstValue(factory.createLiteral(42), host).getBoolean()) + .toThrowError('Unsupported syntax, expected a boolean literal.'); + }); + }); + + describe('isObject', () => { + it('should return true if the value is an object literal', () => { + expect(new AstValue(nestedObj, host).isObject()).toEqual(true); + }); + + it('should return false if the value is not an object literal', () => { + expect(new AstValue(factory.createLiteral(42), host).isObject()).toEqual(false); + }); + }); + + describe('getObject', () => { + it('should return the AstObject value of the AstValue', () => { + expect(new AstValue(nestedObj, host).getObject()).toEqual(AstObject.parse(nestedObj, host)); + }); + + it('should throw an error if the property is not an object literal', () => { + expect(() => new AstValue(factory.createLiteral(42), host).getObject()) + .toThrowError('Unsupported syntax, expected an object literal.'); + }); + }); + + describe('isArray', () => { + it('should return true if the value is an array literal', () => { + expect(new AstValue(nestedArray, host).isArray()).toEqual(true); + }); + + it('should return false if the value is not an object literal', () => { + expect(new AstValue(factory.createLiteral(42), host).isArray()).toEqual(false); + }); + }); + + describe('getArray', () => { + it('should return an array of AstValue objects from the AstValue', () => { + expect(new AstValue(nestedArray, host).getArray()).toEqual([ + new AstValue(factory.createLiteral(1), host), + new AstValue(factory.createLiteral(2), host), + ]); + }); + + it('should throw an error if the property is not an array', () => { + expect(() => new AstValue(factory.createLiteral(42), host).getArray()) + .toThrowError('Unsupported syntax, expected an array literal.'); + }); + }); + + describe('isFunction', () => { + it('should return true if the value is a function expression', () => { + const funcExpr = factory.createFunctionExpression( + 'foo', [], + factory.createBlock([factory.createReturnStatement(factory.createLiteral(42))])); + expect(new AstValue(funcExpr, host).isFunction()).toEqual(true); + }); + + it('should return false if the value is not a function expression', () => { + expect(new AstValue(factory.createLiteral(42), host).isFunction()).toEqual(false); + }); + }); + + describe('getFunctionReturnValue', () => { + it('should return the "return value" of the function expression', () => { + const funcExpr = factory.createFunctionExpression( + 'foo', [], + factory.createBlock([factory.createReturnStatement(factory.createLiteral(42))])); + expect(new AstValue(funcExpr, host).getFunctionReturnValue()) + .toEqual(new AstValue(factory.createLiteral(42), host)); + }); + + it('should throw an error if the property is not a function expression', () => { + expect(() => new AstValue(factory.createLiteral(42), host).getFunctionReturnValue()) + .toThrowError('Unsupported syntax, expected a function.'); + }); + + it('should throw an error if the property is a function expression with no return value', + () => { + const funcExpr = factory.createFunctionExpression( + 'foo', [], factory.createBlock([factory.createExpressionStatement( + factory.createLiteral('do nothing'))])); + expect(() => new AstValue(funcExpr, host).getFunctionReturnValue()) + .toThrowError( + 'Unsupported syntax, expected a function body with a single return statement.'); + }); + }); + + describe('getOpaque()', () => { + it('should return the value wrapped in a `WrappedNodeExpr`', () => { + expect(new AstValue(factory.createLiteral(42), host).getOpaque()) + .toEqual(jasmine.any(WrappedNodeExpr)); + expect(new AstValue(factory.createLiteral(42), host).getOpaque().node) + .toEqual(factory.createLiteral(42)); + }); + }); + + describe('getRange()', () => { + it('should return the source range of the AST node', () => { + const file = ts.createSourceFile( + 'test.ts', '// preamble\nx = \'moo\';', ts.ScriptTarget.ES2015, + /* setParentNodes */ true); + + // Grab the `'moo'` string literal from the generated AST + const stmt = file.statements[0] as ts.ExpressionStatement; + const mooString = + (stmt.expression as ts.AssignmentExpression>).right; + + // Check that this string literal has the expected range. + expect(new AstValue(mooString, host).getRange()) + .toEqual({startLine: 1, startCol: 4, startPos: 16, endPos: 21}); + }); + }); +}); diff --git a/packages/compiler-cli/linker/test/file_linker/emit_scopes/emit_scope_spec.ts b/packages/compiler-cli/linker/test/file_linker/emit_scopes/emit_scope_spec.ts new file mode 100644 index 0000000000..3484f3978d --- /dev/null +++ b/packages/compiler-cli/linker/test/file_linker/emit_scopes/emit_scope_spec.ts @@ -0,0 +1,77 @@ +/** + * @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 o from '@angular/compiler/src/output/output_ast'; +import * as ts from 'typescript'; + +import {TypeScriptAstFactory} from '../../../../src/ngtsc/translator'; +import {TypeScriptAstHost} from '../../../src/ast/typescript/typescript_ast_host'; +import {EmitScope} from '../../../src/file_linker/emit_scopes/emit_scope'; +import {LinkerEnvironment} from '../../../src/file_linker/linker_environment'; +import {DEFAULT_LINKER_OPTIONS} from '../../../src/file_linker/linker_options'; +import {generate} from '../helpers'; + +describe('EmitScope', () => { + describe('translateDefinition()', () => { + it('should translate the given output AST into a TExpression', () => { + const factory = new TypeScriptAstFactory(); + const linkerEnvironment = LinkerEnvironment.create( + new TypeScriptAstHost(), factory, DEFAULT_LINKER_OPTIONS); + const ngImport = factory.createIdentifier('core'); + const emitScope = new EmitScope(ngImport, linkerEnvironment); + + const def = emitScope.translateDefinition(o.fn([], [], null, null, 'foo')); + expect(generate(def)).toEqual('function foo() { }'); + }); + + it('should use the `ngImport` idenfifier for imports when translating', () => { + const factory = new TypeScriptAstFactory(); + const linkerEnvironment = LinkerEnvironment.create( + new TypeScriptAstHost(), factory, DEFAULT_LINKER_OPTIONS); + const ngImport = factory.createIdentifier('core'); + const emitScope = new EmitScope(ngImport, linkerEnvironment); + + const coreImportRef = new o.ExternalReference('@angular/core', 'foo'); + const def = emitScope.translateDefinition(o.importExpr(coreImportRef).callMethod('bar', [])); + expect(generate(def)).toEqual('core.foo.bar()'); + }); + + it('should not emit any shared constants in the replacement expression', () => { + const factory = new TypeScriptAstFactory(); + const linkerEnvironment = LinkerEnvironment.create( + new TypeScriptAstHost(), factory, DEFAULT_LINKER_OPTIONS); + const ngImport = factory.createIdentifier('core'); + const emitScope = new EmitScope(ngImport, linkerEnvironment); + + const constArray = o.literalArr([o.literal('CONST')]); + // We have to add the constant twice or it will not create a shared statement + emitScope.constantPool.getConstLiteral(constArray); + emitScope.constantPool.getConstLiteral(constArray); + + const def = emitScope.translateDefinition(o.fn([], [], null, null, 'foo')); + expect(generate(def)).toEqual('function foo() { }'); + }); + }); + + describe('getConstantStatements()', () => { + it('should return any constant statements that were added to the `constantPool`', () => { + const factory = new TypeScriptAstFactory(); + const linkerEnvironment = LinkerEnvironment.create( + new TypeScriptAstHost(), factory, DEFAULT_LINKER_OPTIONS); + const ngImport = factory.createIdentifier('core'); + const emitScope = new EmitScope(ngImport, linkerEnvironment); + + const constArray = o.literalArr([o.literal('CONST')]); + // We have to add the constant twice or it will not create a shared statement + emitScope.constantPool.getConstLiteral(constArray); + emitScope.constantPool.getConstLiteral(constArray); + + const statements = emitScope.getConstantStatements(); + expect(statements.map(generate)).toEqual(['const _c0 = ["CONST"];']); + }); + }); +}); diff --git a/packages/compiler-cli/linker/test/file_linker/emit_scopes/iief_emit_scope_spec.ts b/packages/compiler-cli/linker/test/file_linker/emit_scopes/iief_emit_scope_spec.ts new file mode 100644 index 0000000000..b4d12f8269 --- /dev/null +++ b/packages/compiler-cli/linker/test/file_linker/emit_scopes/iief_emit_scope_spec.ts @@ -0,0 +1,71 @@ +/** + * @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 o from '@angular/compiler/src/output/output_ast'; +import * as ts from 'typescript'; + +import {TypeScriptAstFactory} from '../../../../src/ngtsc/translator'; +import {TypeScriptAstHost} from '../../../src/ast/typescript/typescript_ast_host'; +import {IifeEmitScope} from '../../../src/file_linker/emit_scopes/iife_emit_scope'; +import {LinkerEnvironment} from '../../../src/file_linker/linker_environment'; +import {DEFAULT_LINKER_OPTIONS} from '../../../src/file_linker/linker_options'; +import {generate} from '../helpers'; + +describe('IifeEmitScope', () => { + describe('translateDefinition()', () => { + it('should translate the given output AST into a TExpression, wrapped in an IIFE', () => { + const factory = new TypeScriptAstFactory(); + const linkerEnvironment = LinkerEnvironment.create( + new TypeScriptAstHost(), factory, DEFAULT_LINKER_OPTIONS); + const ngImport = factory.createIdentifier('core'); + const emitScope = new IifeEmitScope(ngImport, linkerEnvironment); + + const def = emitScope.translateDefinition(o.fn([], [], null, null, 'foo')); + expect(generate(def)).toEqual('function () { return function foo() { }; }()'); + }); + + it('should use the `ngImport` idenfifier for imports when translating', () => { + const factory = new TypeScriptAstFactory(); + const linkerEnvironment = LinkerEnvironment.create( + new TypeScriptAstHost(), factory, DEFAULT_LINKER_OPTIONS); + const ngImport = factory.createIdentifier('core'); + const emitScope = new IifeEmitScope(ngImport, linkerEnvironment); + + const coreImportRef = new o.ExternalReference('@angular/core', 'foo'); + const def = emitScope.translateDefinition(o.importExpr(coreImportRef).callMethod('bar', [])); + expect(generate(def)).toEqual('function () { return core.foo.bar(); }()'); + }); + + it('should emit any shared constants in the replacement expression IIFE', () => { + const factory = new TypeScriptAstFactory(); + const linkerEnvironment = LinkerEnvironment.create( + new TypeScriptAstHost(), factory, DEFAULT_LINKER_OPTIONS); + const ngImport = factory.createIdentifier('core'); + const emitScope = new IifeEmitScope(ngImport, linkerEnvironment); + + const constArray = o.literalArr([o.literal('CONST')]); + // We have to add the constant twice or it will not create a shared statement + emitScope.constantPool.getConstLiteral(constArray); + emitScope.constantPool.getConstLiteral(constArray); + + const def = emitScope.translateDefinition(o.fn([], [], null, null, 'foo')); + expect(generate(def)) + .toEqual('function () { const _c0 = ["CONST"]; return function foo() { }; }()'); + }); + }); + + describe('getConstantStatements()', () => { + it('should throw an error', () => { + const factory = new TypeScriptAstFactory(); + const linkerEnvironment = LinkerEnvironment.create( + new TypeScriptAstHost(), factory, DEFAULT_LINKER_OPTIONS); + const ngImport = factory.createIdentifier('core'); + const emitScope = new IifeEmitScope(ngImport, linkerEnvironment); + expect(() => emitScope.getConstantStatements()).toThrowError(); + }); + }); +}); diff --git a/packages/compiler-cli/linker/test/file_linker/file_linker_spec.ts b/packages/compiler-cli/linker/test/file_linker/file_linker_spec.ts new file mode 100644 index 0000000000..68eeee4ddf --- /dev/null +++ b/packages/compiler-cli/linker/test/file_linker/file_linker_spec.ts @@ -0,0 +1,191 @@ +/** + * @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 o from '@angular/compiler/src/output/output_ast'; +import * as ts from 'typescript'; + +import {TypeScriptAstFactory} from '../../../src/ngtsc/translator'; +import {AstHost} from '../../src/ast/ast_host'; +import {TypeScriptAstHost} from '../../src/ast/typescript/typescript_ast_host'; +import {DeclarationScope} from '../../src/file_linker/declaration_scope'; +import {FileLinker} from '../../src/file_linker/file_linker'; +import {LinkerEnvironment} from '../../src/file_linker/linker_environment'; +import {DEFAULT_LINKER_OPTIONS} from '../../src/file_linker/linker_options'; +import {PartialDirectiveLinkerVersion1} from '../../src/file_linker/partial_linkers/partial_directive_linker_1'; +import {generate} from './helpers'; + +describe('FileLinker', () => { + let factory: TypeScriptAstFactory; + beforeEach(() => factory = new TypeScriptAstFactory()); + + describe('isPartialDeclaration()', () => { + it('should return true if the callee is recognized', () => { + const {fileLinker} = createFileLinker(); + expect(fileLinker.isPartialDeclaration('$ngDeclareDirective')).toBe(true); + expect(fileLinker.isPartialDeclaration('$ngDeclareComponent')).toBe(true); + }); + + it('should return false if the callee is not recognized', () => { + const {fileLinker} = createFileLinker(); + expect(fileLinker.isPartialDeclaration('$foo')).toBe(false); + }); + }); + + describe('linkPartialDeclaration()', () => { + it('should throw an error if the function name is not recognised', () => { + const {fileLinker} = createFileLinker(); + const version = factory.createLiteral(1); + const ngImport = factory.createIdentifier('core'); + const declarationArg = factory.createObjectLiteral([ + {propertyName: 'version', quoted: false, value: version}, + {propertyName: 'ngImport', quoted: false, value: ngImport}, + ]); + expect( + () => fileLinker.linkPartialDeclaration( + 'foo', [declarationArg], new MockDeclarationScope())) + .toThrowError('Unknown partial declaration function foo.'); + }); + + it('should throw an error if the metadata object does not have a `version` property', () => { + const {fileLinker} = createFileLinker(); + const ngImport = factory.createIdentifier('core'); + const declarationArg = factory.createObjectLiteral([ + {propertyName: 'ngImport', quoted: false, value: ngImport}, + ]); + expect( + () => fileLinker.linkPartialDeclaration( + '$ngDeclareDirective', [declarationArg], new MockDeclarationScope())) + .toThrowError(`Expected property 'version' to be present.`); + }); + + it('should throw an error if the metadata object does not have a `ngImport` property', () => { + const {fileLinker} = createFileLinker(); + const ngImport = factory.createIdentifier('core'); + const declarationArg = factory.createObjectLiteral([ + {propertyName: 'version', quoted: false, value: ngImport}, + ]); + expect( + () => fileLinker.linkPartialDeclaration( + '$ngDeclareDirective', [declarationArg], new MockDeclarationScope())) + .toThrowError(`Expected property 'ngImport' to be present.`); + }); + + it('should call `linkPartialDeclaration()` on the appropriate partial compiler', () => { + const {fileLinker} = createFileLinker(); + const compileSpy = spyOn(PartialDirectiveLinkerVersion1.prototype, 'linkPartialDeclaration') + .and.returnValue(o.literal('compilation result')); + + const ngImport = factory.createIdentifier('core'); + const version = factory.createLiteral(1); + const declarationArg = factory.createObjectLiteral([ + {propertyName: 'ngImport', quoted: false, value: ngImport}, + {propertyName: 'version', quoted: false, value: version}, + ]); + + const compilationResult = fileLinker.linkPartialDeclaration( + '$ngDeclareDirective', [declarationArg], new MockDeclarationScope()); + + expect(compilationResult).toEqual(factory.createLiteral('compilation result')); + expect(compileSpy).toHaveBeenCalled(); + expect(compileSpy.calls.mostRecent().args[3].getNode('ngImport')).toBe(ngImport); + }); + }); + + describe('getConstantStatements()', () => { + it('should capture shared constant values', () => { + const {fileLinker} = createFileLinker(); + spyOnLinkPartialDeclarationWithConstants(o.literal('REPLACEMENT')); + + // Here we use the `core` idenfifier for `ngImport` to trigger the use of a shared scope for + // constant statements. + const declarationArg = factory.createObjectLiteral([ + {propertyName: 'ngImport', quoted: false, value: factory.createIdentifier('core')}, + {propertyName: 'version', quoted: false, value: factory.createLiteral(1)}, + ]); + + const replacement = fileLinker.linkPartialDeclaration( + '$ngDeclareDirective', [declarationArg], new MockDeclarationScope()); + expect(generate(replacement)).toEqual('"REPLACEMENT"'); + + const results = fileLinker.getConstantStatements(); + expect(results.length).toEqual(1); + const {constantScope, statements} = results[0]; + expect(constantScope).toBe(MockConstantScopeRef.singleton); + expect(statements.map(generate)).toEqual(['const _c0 = [1];']); + }); + + it('should be no shared constant statements to capture when they are emitted into the replacement IIFE', + () => { + const {fileLinker} = createFileLinker(); + spyOnLinkPartialDeclarationWithConstants(o.literal('REPLACEMENT')); + + // Here we use a string literal `"not-a-module"` for `ngImport` to cause constant + // statements to be emitted in an IIFE rather than added to the shared constant scope. + const declarationArg = factory.createObjectLiteral([ + {propertyName: 'ngImport', quoted: false, value: factory.createLiteral('not-a-module')}, + {propertyName: 'version', quoted: false, value: factory.createLiteral(1)}, + ]); + + const replacement = fileLinker.linkPartialDeclaration( + '$ngDeclareDirective', [declarationArg], new MockDeclarationScope()); + expect(generate(replacement)) + .toEqual('function () { const _c0 = [1]; return "REPLACEMENT"; }()'); + + const results = fileLinker.getConstantStatements(); + expect(results.length).toEqual(0); + }); + }); + + function createFileLinker(): { + host: AstHost, + fileLinker: FileLinker + } { + const linkerEnvironment = LinkerEnvironment.create( + new TypeScriptAstHost(), new TypeScriptAstFactory(), DEFAULT_LINKER_OPTIONS); + const fileLinker = new FileLinker( + linkerEnvironment, 'test.js', '// test code'); + return {host: linkerEnvironment.host, fileLinker}; + } +}); + + +/** + * This mock implementation of `DeclarationScope` will return a singleton instance of + * `MockConstantScopeRef` if the expression is an identifier, or `null` otherwise. + * + * This way we can simulate whether the constants will be shared or inlined into an IIFE. + */ +class MockDeclarationScope implements DeclarationScope { + getConstantScopeRef(expression: ts.Expression): MockConstantScopeRef|null { + if (ts.isIdentifier(expression)) { + return MockConstantScopeRef.singleton; + } else { + return null; + } + } +} + +class MockConstantScopeRef { + private constructor() {} + static singleton = new MockDeclarationScope(); +} + +/** + * Spy on the `PartialDirectiveLinkerVersion1.linkPartialDeclaration()` method, triggering + * shared constants to be created. + */ +function spyOnLinkPartialDeclarationWithConstants(replacement: o.Expression) { + let callCount = 0; + spyOn(PartialDirectiveLinkerVersion1.prototype, 'linkPartialDeclaration') + .and.callFake(((sourceUrl, code, constantPool) => { + const constArray = o.literalArr([o.literal(++callCount)]); + // We have to add the constant twice or it will not create a shared statement + constantPool.getConstLiteral(constArray); + constantPool.getConstLiteral(constArray); + return replacement; + }) as typeof PartialDirectiveLinkerVersion1.prototype.linkPartialDeclaration); +} diff --git a/packages/compiler-cli/linker/test/file_linker/helpers.ts b/packages/compiler-cli/linker/test/file_linker/helpers.ts new file mode 100644 index 0000000000..5faa908ce7 --- /dev/null +++ b/packages/compiler-cli/linker/test/file_linker/helpers.ts @@ -0,0 +1,17 @@ +/** + * @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'; + +/** + * A simple helper to render a TS Node as a string. + */ +export function generate(node: ts.Node): string { + const printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed}); + const sf = ts.createSourceFile('test.ts', '', ts.ScriptTarget.ES2015, true); + return printer.printNode(ts.EmitHint.Unspecified, node, sf); +} diff --git a/packages/compiler-cli/linker/test/file_linker/partial_linkers/partial_linker_selector_spec.ts b/packages/compiler-cli/linker/test/file_linker/partial_linkers/partial_linker_selector_spec.ts new file mode 100644 index 0000000000..c04b92e2d9 --- /dev/null +++ b/packages/compiler-cli/linker/test/file_linker/partial_linkers/partial_linker_selector_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 {PartialComponentLinkerVersion1} from '../../../src/file_linker/partial_linkers/partial_component_linker_1'; +import {PartialDirectiveLinkerVersion1} from '../../../src/file_linker/partial_linkers/partial_directive_linker_1'; +import {PartialLinkerSelector} from '../../../src/file_linker/partial_linkers/partial_linker_selector'; + +describe('PartialLinkerSelector', () => { + describe('supportsDeclaration()', () => { + it('should return true if there is at least one linker that matches the given function name', + () => { + const selector = new PartialLinkerSelector(); + expect(selector.supportsDeclaration('$ngDeclareDirective')).toBe(true); + expect(selector.supportsDeclaration('$ngDeclareComponent')).toBe(true); + expect(selector.supportsDeclaration('$foo')).toBe(false); + }); + }); + + describe('getLinker()', () => { + it('should return the linker that matches the name and version number', () => { + const selector = new PartialLinkerSelector(); + expect(selector.getLinker('$ngDeclareDirective', 1)) + .toBeInstanceOf(PartialDirectiveLinkerVersion1); + expect(selector.getLinker('$ngDeclareComponent', 1)) + .toBeInstanceOf(PartialComponentLinkerVersion1); + }); + + it('should throw an error if there is no linker that matches the given name or version', () => { + const selector = new PartialLinkerSelector(); + expect(() => selector.getLinker('$foo', 1)) + .toThrowError('Unknown partial declaration function $foo.'); + expect(() => selector.getLinker('$ngDeclareDirective', 2)) + .toThrowError('Unsupported partial declaration version 2 for $ngDeclareDirective.'); + }); + }); +}); diff --git a/packages/compiler-cli/linker/test/file_linker/translator_spec.ts b/packages/compiler-cli/linker/test/file_linker/translator_spec.ts new file mode 100644 index 0000000000..31d0401f0b --- /dev/null +++ b/packages/compiler-cli/linker/test/file_linker/translator_spec.ts @@ -0,0 +1,47 @@ +/** + * @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 o from '@angular/compiler'; +import {ImportGenerator, NamedImport, TypeScriptAstFactory} from '@angular/compiler-cli/src/ngtsc/translator'; +import * as ts from 'typescript'; + +import {Translator} from '../../src/file_linker/translator'; +import {generate} from './helpers'; + +describe('Translator', () => { + let factory: TypeScriptAstFactory; + beforeEach(() => factory = new TypeScriptAstFactory()); + + describe('translateExpression()', () => { + it('should generate expression specific output', () => { + const translator = new Translator(factory); + const outputAst = new o.WriteVarExpr('foo', new o.LiteralExpr(42)); + const translated = translator.translateExpression(outputAst, new MockImportGenerator()); + expect(generate(translated)).toEqual('(foo = 42)'); + }); + }); + + describe('translateStatement()', () => { + it('should generate statement specific output', () => { + const translator = new Translator(factory); + const outputAst = new o.ExpressionStatement(new o.WriteVarExpr('foo', new o.LiteralExpr(42))); + const translated = translator.translateStatement(outputAst, new MockImportGenerator()); + expect(generate(translated)).toEqual('foo = 42;'); + }); + }); + class MockImportGenerator implements ImportGenerator { + generateNamespaceImport(moduleName: string): ts.Expression { + return factory.createLiteral(moduleName); + } + generateNamedImport(moduleName: string, originalSymbol: string): NamedImport { + return { + moduleImport: factory.createLiteral(moduleName), + symbol: originalSymbol, + }; + } + } +}); diff --git a/packages/compiler-cli/linker/test/linker_import_generator_spec.ts b/packages/compiler-cli/linker/test/linker_import_generator_spec.ts index bb4af55fbe..3202ada682 100644 --- a/packages/compiler-cli/linker/test/linker_import_generator_spec.ts +++ b/packages/compiler-cli/linker/test/linker_import_generator_spec.ts @@ -8,7 +8,7 @@ import {LinkerImportGenerator} from '../src/linker_import_generator'; const ngImport = { - type: 'ngImport' + ngImport: true }; describe('LinkerImportGenerator', () => { diff --git a/packages/compiler-cli/src/ngtsc/translator/index.ts b/packages/compiler-cli/src/ngtsc/translator/index.ts index 81e21a189a..8c0dfef49f 100644 --- a/packages/compiler-cli/src/ngtsc/translator/index.ts +++ b/packages/compiler-cli/src/ngtsc/translator/index.ts @@ -8,8 +8,9 @@ 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 {Context} from './src/context'; export {ImportManager} from './src/import_manager'; -export {RecordWrappedNodeExprFn} from './src/translator'; +export {ExpressionTranslatorVisitor, RecordWrappedNodeExprFn, TranslatorOptions} from './src/translator'; export {translateType} from './src/type_translator'; export {attachComments, TypeScriptAstFactory} from './src/typescript_ast_factory'; export {translateExpression, translateStatement} from './src/typescript_translator';