From 16c954706c14d583eb6ca3dcf88dcb138f490fe8 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 14:08:59 +0100 Subject: [PATCH 01/15] style(ivy): remove underscore from TypeScriptReflectionHost._getDeclarationOfSymbol The linter complains that non-private members must be marked with `@internal` if they start with an underscore. --- packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts b/packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts index 014abbae890f..5e93aa402df3 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts @@ -119,7 +119,7 @@ export class TypeScriptReflectionHost implements ReflectionHost { } this.checker.getExportsOfModule(symbol).forEach(exportSymbol => { // Map each exported Symbol to a Declaration and add it to the map. - const decl = this._getDeclarationOfSymbol(exportSymbol); + const decl = this.getDeclarationOfSymbol(exportSymbol); if (decl !== null) { map.set(exportSymbol.name, decl); } @@ -138,7 +138,7 @@ export class TypeScriptReflectionHost implements ReflectionHost { if (symbol === undefined) { return null; } - return this._getDeclarationOfSymbol(symbol); + return this.getDeclarationOfSymbol(symbol); } /** @@ -146,7 +146,7 @@ export class TypeScriptReflectionHost implements ReflectionHost { * * @internal */ - protected _getDeclarationOfSymbol(symbol: ts.Symbol): Declaration|null { + protected getDeclarationOfSymbol(symbol: ts.Symbol): Declaration|null { let viaModule: string|null = null; // Look through the Symbol's immediate declarations, and see if any of them are import-type // statements. From e1c4e156df4efdaf591cc609d9113eabffc7385b Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 07:16:34 +0100 Subject: [PATCH 02/15] refactor(ivy): `TypeScriptReflectionHost.isClass` cannot be a type discriminator The `ReflectionHost` interface that is being implemented only expects a return value of `boolean`. Moreover, if you want to extend this class to support non-TS code formats, e.g. ES5, the result of this call returning true does not mean that the `node` is a `ClassDeclaration`. It could be a `VariableDeclaration`. --- packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts b/packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts index 5e93aa402df3..5d604f8ec075 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/reflector.ts @@ -127,7 +127,7 @@ export class TypeScriptReflectionHost implements ReflectionHost { return map; } - isClass(node: ts.Declaration): node is ts.ClassDeclaration { + isClass(node: ts.Declaration): boolean { // In TypeScript code, classes are ts.ClassDeclarations. return ts.isClassDeclaration(node); } From 5d1efa5ba68155489522482b53a1700609c99d15 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 08:42:03 +0100 Subject: [PATCH 03/15] fix(ivy): make ngtsc `ClassMember` `node` and `declaration` optional Not all code formats have associated nodes and declarations for class members. --- packages/compiler-cli/src/ngtsc/host/src/reflection.ts | 4 ++-- packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/host/src/reflection.ts b/packages/compiler-cli/src/ngtsc/host/src/reflection.ts index d47f27c2697a..bfea18a9ac0d 100644 --- a/packages/compiler-cli/src/ngtsc/host/src/reflection.ts +++ b/packages/compiler-cli/src/ngtsc/host/src/reflection.ts @@ -53,9 +53,9 @@ export enum ClassMemberKind { */ export interface ClassMember { /** - * TypeScript reference to the class member itself. + * TypeScript reference to the class member itself, or null if it is not applicable. */ - node: ts.Node; + node: ts.Node|null; /** * Indication of which type of member this is (property, method, etc). diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts b/packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts index 66f4e6664889..17b831087fb5 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts @@ -544,7 +544,7 @@ class StaticInterpreter { throw new Error(`attempting to call something that is not a function: ${lhs}`); } else if (!isFunctionOrMethodReference(lhs)) { throw new Error( - `calling something that is not a function declaration? ${ts.SyntaxKind[lhs.node.kind]}`); + `calling something that is not a function declaration? ${ts.SyntaxKind[lhs.node.kind]} (${node.getText()})`); } const fn = lhs.node; From 2cccb2368851f4f0cc6eeec26cba36ae2847fc2c Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 08:43:24 +0100 Subject: [PATCH 04/15] refactor(ivy): allow `ImportManager` to have configurable prefix The ngcc compiler will want to specify its own prefix when rendering definitions. --- packages/compiler-cli/src/ngtsc/transform/src/translator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/transform/src/translator.ts b/packages/compiler-cli/src/ngtsc/transform/src/translator.ts index f54aaef5246f..095e30683e3b 100644 --- a/packages/compiler-cli/src/ngtsc/transform/src/translator.ts +++ b/packages/compiler-cli/src/ngtsc/transform/src/translator.ts @@ -52,11 +52,11 @@ export class ImportManager { private moduleToIndex = new Map(); private nextIndex = 0; - constructor(private isCore: boolean) {} + constructor(private isCore: boolean, private prefix = 'i') {} generateNamedImport(moduleName: string, symbol: string): string { if (!this.moduleToIndex.has(moduleName)) { - this.moduleToIndex.set(moduleName, `i${this.nextIndex++}`); + this.moduleToIndex.set(moduleName, `${this.prefix}${this.nextIndex++}`); } if (this.isCore && moduleName === '@angular/core' && !CORE_SUPPORTED_SYMBOLS.has(symbol)) { throw new Error(`Importing unexpected symbol ${symbol} while compiling core`); From 07e2ea8fca8ebab359ee79f93356b30084712fcc Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 08:45:32 +0100 Subject: [PATCH 05/15] fix(ivy): allow `FunctionExpression` to indicate a method declaration In some code formats (e.g. ES5) methods can actually be function expressions. For example: ```js function MyClass() {} // this static method is declared as a function expression MyClass.staticMethod = function() { ... }; ``` --- packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts b/packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts index 17b831087fb5..acc8ff3f17ff 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/resolver.ts @@ -279,7 +279,7 @@ interface Context { absoluteModuleName: string|null; scope: Scope; foreignFunctionResolver? - (ref: Reference, + (ref: Reference, args: ReadonlyArray): ts.Expression|null; } @@ -528,7 +528,7 @@ class StaticInterpreter { value = this.visitExpression(member.value, context); } else if (member.implementation !== null) { value = new NodeReference(member.implementation, absoluteModuleName); - } else { + } else if (member.node) { value = new NodeReference(member.node, absoluteModuleName); } } @@ -670,7 +670,7 @@ class StaticInterpreter { } function isFunctionOrMethodReference(ref: Reference): - ref is Reference { + ref is Reference { return ts.isFunctionDeclaration(ref.node) || ts.isMethodDeclaration(ref.node); } From d5d232dfb6604557a455448ee95b094617c6b57f Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 08:46:43 +0100 Subject: [PATCH 06/15] test(ivy): allow `makeProgram` to be more configurable This supports use cases needed by ngcc, where the compilation needs to be configured for JavaScript differently to normal TypeScript. --- .../src/ngtsc/testing/in_memory_typescript.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/testing/in_memory_typescript.ts b/packages/compiler-cli/src/ngtsc/testing/in_memory_typescript.ts index aea082817cd7..b76ab4724aef 100644 --- a/packages/compiler-cli/src/ngtsc/testing/in_memory_typescript.ts +++ b/packages/compiler-cli/src/ngtsc/testing/in_memory_typescript.ts @@ -9,20 +9,31 @@ import * as path from 'path'; import * as ts from 'typescript'; -export function makeProgram(files: {name: string, contents: string}[]): - {program: ts.Program, host: ts.CompilerHost} { +export function makeProgram( + files: {name: string, contents: string}[], + options?: ts.CompilerOptions): {program: ts.Program, host: ts.CompilerHost} { const host = new InMemoryHost(); files.forEach(file => host.writeFile(file.name, file.contents)); const rootNames = files.map(file => host.getCanonicalFileName(file.name)); const program = ts.createProgram( - rootNames, - {noLib: true, experimentalDecorators: true, moduleResolution: ts.ModuleResolutionKind.NodeJs}, + rootNames, { + noLib: true, + experimentalDecorators: true, + moduleResolution: ts.ModuleResolutionKind.NodeJs, ...options + }, host); const diags = [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()]; if (diags.length > 0) { - throw new Error( - `Typescript diagnostics failed! ${diags.map(diag => diag.messageText).join(', ')}`); + const errors = diags.map(diagnostic => { + let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + if (diagnostic.file) { + const {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start !); + message = `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`; + } + return `Error: ${message}`; + }); + throw new Error(`Typescript diagnostics failed! ${errors.join(', ')}`); } return {program, host}; } @@ -39,7 +50,7 @@ export class InMemoryHost implements ts.CompilerHost { onError && onError(`File does not exist: ${this.getCanonicalFileName(fileName)})`); return undefined; } - return ts.createSourceFile(fileName, contents, languageVersion, undefined, ts.ScriptKind.TS); + return ts.createSourceFile(fileName, contents, languageVersion); } getDefaultLibFileName(options: ts.CompilerOptions): string { return '/lib.d.ts'; } From 77026a4765104c25b46c19992ea5a07b6c8af030 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 07:08:48 +0100 Subject: [PATCH 07/15] build: add dependencies to be used by ngcc --- package.json | 7 ++++++- tools/ng_setup_workspace.bzl | 6 +++++- yarn.lock | 36 ++++++++++++++++++++++++++++++++---- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2f3bcb8861be..6ae7d73323d2 100644 --- a/package.json +++ b/package.json @@ -46,12 +46,14 @@ "@types/base64-js": "1.2.5", "@types/chai": "^4.1.2", "@types/chokidar": "1.7.3", + "@types/convert-source-map": "^1.5.1", "@types/diff": "^3.2.2", "@types/fs-extra": "4.0.2", "@types/hammerjs": "2.0.35", "@types/jasmine": "^2.8.8", "@types/jasminewd2": "^2.0.3", "@types/minimist": "^1.2.0", + "@types/mock-fs": "^3.6.30", "@types/node": "6.0.88", "@types/selenium-webdriver": "3.0.7", "@types/shelljs": "^0.7.8", @@ -72,6 +74,7 @@ "cldr-data-downloader": "0.3.2", "cldrjs": "0.5.0", "conventional-changelog": "1.1.0", + "convert-source-map": "^1.5.1", "cors": "2.8.4", "diff": "^3.5.0", "domino": "2.0.1", @@ -97,7 +100,9 @@ "karma-sauce-launcher": "^1.2.0", "karma-sourcemap-loader": "^0.3.7", "madge": "0.5.0", + "magic-string": "^0.25.0", "minimist": "1.2.0", + "mock-fs": "^4.5.0", "mutation-observer": "^1.0.3", "node-uuid": "1.4.8", "protobufjs": "5.0.0", @@ -110,7 +115,7 @@ "selenium-webdriver": "3.5.0", "semver": "5.4.1", "shelljs": "^0.8.1", - "source-map": "0.5.7", + "source-map": "^0.6.1", "source-map-support": "0.4.18", "systemjs": "0.18.10", "tsickle": "0.32", diff --git a/tools/ng_setup_workspace.bzl b/tools/ng_setup_workspace.bzl index 1e3c90bcacb4..e4f7209004dd 100644 --- a/tools/ng_setup_workspace.bzl +++ b/tools/ng_setup_workspace.bzl @@ -21,7 +21,7 @@ def ng_setup_workspace(): data = ["@angular//:tools/yarn/check-yarn.js", "@angular//:tools/postinstall-patches.js"], node_modules_filegroup = """ filegroup( - name = "node_modules", + name = "node_modules", srcs = glob(["/".join([ "node_modules", pkg, @@ -61,6 +61,7 @@ filegroup( "class-utils", "co", "collection-visit", + "convert-source-map", "combined-stream", "component-emitter", "concat-map", @@ -131,6 +132,7 @@ filegroup( "kind-of", "long", "lru-cache", + "magic-string", "map-cache", "map-visit", "math-random", @@ -140,6 +142,7 @@ filegroup( "minimatch", "minimist", "mixin-deep", + "mock-fs", "nanomatch", "normalize-path", "oauth-sign", @@ -190,6 +193,7 @@ filegroup( "source-map-resolve", "source-map-support", "source-map-url", + "sourcemap-codec", "split-string", "sshpk", "static-extend", diff --git a/yarn.lock b/yarn.lock index 86a9be8662d2..88c7dd25a224 100644 --- a/yarn.lock +++ b/yarn.lock @@ -48,6 +48,10 @@ dependencies: "@types/node" "*" +"@types/convert-source-map@^1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-1.5.1.tgz#d4d180dd6adc5cb68ad99bd56e03d637881f4616" + "@types/diff@^3.2.2": version "3.5.1" resolved "https://registry.yarnpkg.com/@types/diff/-/diff-3.5.1.tgz#30253f6e177564ad7da707b1ebe46d3eade71706" @@ -92,6 +96,12 @@ version "1.2.0" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6" +"@types/mock-fs@^3.6.30": + version "3.6.30" + resolved "https://registry.yarnpkg.com/@types/mock-fs/-/mock-fs-3.6.30.tgz#4d812541e87b23577261a5aa95f704dd3d01e410" + dependencies: + "@types/node" "*" + "@types/node@*": version "10.5.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.5.2.tgz#f19f05314d5421fe37e74153254201a7bf00a707" @@ -1604,6 +1614,10 @@ conventional-commits-parser@^2.1.0, conventional-commits-parser@^2.1.7: through2 "^2.0.0" trim-off-newlines "^1.0.0" +convert-source-map@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.1.tgz#b8278097b9bc229365de5c62cf5fcaed8b5599e5" + cookie-parser@~1.3.5: version "1.3.5" resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.3.5.tgz#9d755570fb5d17890771227a02314d9be7cf8356" @@ -4338,6 +4352,12 @@ magic-string@^0.19.0: dependencies: vlq "^0.2.1" +magic-string@^0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.0.tgz#1f3696f9931ff0a1ed4c132250529e19cad6759b" + dependencies: + sourcemap-codec "^1.4.1" + mailcomposer@4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/mailcomposer/-/mailcomposer-4.0.1.tgz#0e1c44b2a07cf740ee17dc149ba009f19cadfeb4" @@ -4627,6 +4647,10 @@ mkpath@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/mkpath/-/mkpath-0.1.0.tgz#7554a6f8d871834cc97b5462b122c4c124d6de91" +mock-fs@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-4.5.0.tgz#75245b966f7e3defe197b03454af9c5b355594b7" + modify-values@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" @@ -6455,20 +6479,24 @@ source-map@0.1.31: dependencies: amdefine ">=0.0.4" -source-map@0.5.7, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.0, source-map@~0.5.1: - version "0.5.7" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - source-map@^0.4.4, source-map@~0.4.1: version "0.4.4" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" dependencies: amdefine ">=0.0.4" +source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.0, source-map@~0.5.1: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" +sourcemap-codec@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.1.tgz#c8fd92d91889e902a07aee392bdd2c5863958ba2" + sparkles@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/sparkles/-/sparkles-1.0.1.tgz#008db65edce6c50eec0c5e228e1945061dd0437c" From 84315880180ceca009b835db962396e4b5d7226e Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 08:49:56 +0100 Subject: [PATCH 08/15] feat(ivy): ngcc project skeleton --- packages/compiler-cli/BUILD.bazel | 5 +- packages/compiler-cli/package.json | 6 +- packages/compiler-cli/src/ngcc/BUILD.bazel | 21 +++++ packages/compiler-cli/src/ngcc/README.md | 30 +++++++ packages/compiler-cli/src/ngcc/index.ts | 9 ++ packages/compiler-cli/src/ngcc/main-ngcc.ts | 16 ++++ packages/compiler-cli/src/ngcc/src/main.ts | 14 +++ .../compiler-cli/src/ngcc/test/BUILD.bazel | 26 ++++++ packages/compiler-cli/test/ngcc/BUILD.bazel | 28 ++++++ packages/compiler-cli/test/ngcc/ngcc_spec.ts | 88 +++++++++++++++++++ 10 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 packages/compiler-cli/src/ngcc/BUILD.bazel create mode 100644 packages/compiler-cli/src/ngcc/README.md create mode 100644 packages/compiler-cli/src/ngcc/index.ts create mode 100644 packages/compiler-cli/src/ngcc/main-ngcc.ts create mode 100644 packages/compiler-cli/src/ngcc/src/main.ts create mode 100644 packages/compiler-cli/src/ngcc/test/BUILD.bazel create mode 100644 packages/compiler-cli/test/ngcc/BUILD.bazel create mode 100644 packages/compiler-cli/test/ngcc/ngcc_spec.ts diff --git a/packages/compiler-cli/BUILD.bazel b/packages/compiler-cli/BUILD.bazel index 4840768b04f1..09fccb0c68f2 100644 --- a/packages/compiler-cli/BUILD.bazel +++ b/packages/compiler-cli/BUILD.bazel @@ -41,5 +41,8 @@ npm_package( "ivy-local", "release-with-framework", ], - deps = [":compiler-cli"], + deps = [ + ":compiler-cli", + "//packages/compiler-cli/src/ngcc", + ], ) diff --git a/packages/compiler-cli/package.json b/packages/compiler-cli/package.json index bf12320189c7..4529592ff274 100644 --- a/packages/compiler-cli/package.json +++ b/packages/compiler-cli/package.json @@ -5,6 +5,7 @@ "main": "index.js", "typings": "index.d.ts", "bin": { + "ivy-ngcc": "./src/ngcc/main-ngcc.js", "ngc": "./src/main.js", "ng-xi18n": "./src/extract_i18n.js" }, @@ -12,7 +13,10 @@ "reflect-metadata": "^0.1.2", "minimist": "^1.2.0", "tsickle": "^0.32.1", - "chokidar": "^1.4.2" + "chokidar": "^1.4.2", + "convert-source-map": "^1.5.1", + "magic-string": "^0.25.0", + "source-map": "^0.6.1" }, "peerDependencies": { "typescript": ">=2.7.2 <2.10", diff --git a/packages/compiler-cli/src/ngcc/BUILD.bazel b/packages/compiler-cli/src/ngcc/BUILD.bazel new file mode 100644 index 000000000000..9b69823c8230 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/BUILD.bazel @@ -0,0 +1,21 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ts_library") +load("@build_bazel_rules_nodejs//:defs.bzl", "jasmine_node_test") + +ts_library( + name = "ngcc", + srcs = glob([ + "*.ts", + "**/*.ts", + ]), + module_name = "@angular/compiler-cli/src/ngcc", + deps = [ + "//packages:types", + "//packages/compiler", + "//packages/compiler-cli/src/ngtsc/annotations", + "//packages/compiler-cli/src/ngtsc/host", + "//packages/compiler-cli/src/ngtsc/metadata", + "//packages/compiler-cli/src/ngtsc/transform", + ], +) diff --git a/packages/compiler-cli/src/ngcc/README.md b/packages/compiler-cli/src/ngcc/README.md new file mode 100644 index 000000000000..6a3a3844b2c3 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/README.md @@ -0,0 +1,30 @@ +# Angular Compatibility Compiler (ngcc) + +This compiler will convert `node_modules` compiled with `ngc`, into `node_modules` which +appear to have been compiled with `ngtsc`. + +This conversion will allow such "legacy" packages to be used by the Ivy rendering engine. + +## Building + +The project is built using Bazel: + +```bash +bazel build //packages/compiler-cli/src/ngcc +``` + +## Unit Testing + +The unit tests are built and run using Bazel: + +```bash +bazel test //packages/compiler-cli/src/ngcc/test +``` + +## Integration Testing + +There are tests that check the behaviour of the overall executable: + +```bash +bazel test //packages/compiler-cli/test/ngcc +``` diff --git a/packages/compiler-cli/src/ngcc/index.ts b/packages/compiler-cli/src/ngcc/index.ts new file mode 100644 index 000000000000..b7c62ac5220f --- /dev/null +++ b/packages/compiler-cli/src/ngcc/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export {mainNgcc} from './src/main'; diff --git a/packages/compiler-cli/src/ngcc/main-ngcc.ts b/packages/compiler-cli/src/ngcc/main-ngcc.ts new file mode 100644 index 000000000000..08524a6afaad --- /dev/null +++ b/packages/compiler-cli/src/ngcc/main-ngcc.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {mainNgcc} from './src/main'; + +// CLI entry point +if (require.main === module) { + const args = process.argv.slice(2); + process.exitCode = mainNgcc(args); +} diff --git a/packages/compiler-cli/src/ngcc/src/main.ts b/packages/compiler-cli/src/ngcc/src/main.ts new file mode 100644 index 000000000000..b20388f7e463 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/main.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {resolve} from 'path'; + +export function mainNgcc(args: string[]): number { + const packagePath = resolve(args[0]); + + return 0; +} diff --git a/packages/compiler-cli/src/ngcc/test/BUILD.bazel b/packages/compiler-cli/src/ngcc/test/BUILD.bazel new file mode 100644 index 000000000000..c54936a989bb --- /dev/null +++ b/packages/compiler-cli/src/ngcc/test/BUILD.bazel @@ -0,0 +1,26 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ts_library") +load("@build_bazel_rules_nodejs//:defs.bzl", "jasmine_node_test") + +ts_library( + name = "test_lib", + testonly = 1, + srcs = glob([ + "**/*.ts", + ]), + deps = [ + "//packages/compiler-cli/src/ngcc", + "//packages/compiler-cli/src/ngtsc/host", + "//packages/compiler-cli/src/ngtsc/testing", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = ["angular/tools/testing/init_node_no_angular_spec.js"], + deps = [ + ":test_lib", + "//tools/testing:node_no_angular", + ], +) diff --git a/packages/compiler-cli/test/ngcc/BUILD.bazel b/packages/compiler-cli/test/ngcc/BUILD.bazel new file mode 100644 index 000000000000..9745a3f6bd12 --- /dev/null +++ b/packages/compiler-cli/test/ngcc/BUILD.bazel @@ -0,0 +1,28 @@ +load("//tools:defaults.bzl", "ts_library") +load("@build_bazel_rules_nodejs//:defs.bzl", "jasmine_node_test") + +# Integration tests +ts_library( + name = "ngcc_lib", + testonly = 1, + srcs = glob([ + "**/*_spec.ts", + ]), + deps = [ + "//packages/compiler-cli/src/ngcc", + "//packages/compiler-cli/test:test_utils", + ], +) + +jasmine_node_test( + name = "ngcc", + bootstrap = ["angular/tools/testing/init_node_no_angular_spec.js"], + data = [ + "//packages/common:npm_package", + "//packages/core:npm_package", + ], + deps = [ + ":ngcc_lib", + "//tools/testing:node_no_angular", + ], +) diff --git a/packages/compiler-cli/test/ngcc/ngcc_spec.ts b/packages/compiler-cli/test/ngcc/ngcc_spec.ts new file mode 100644 index 000000000000..aab460be005c --- /dev/null +++ b/packages/compiler-cli/test/ngcc/ngcc_spec.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import {cat, find} from 'shelljs'; + +import {mainNgcc} from '../../src/ngcc/src/main'; + +import {TestSupport, isInBazel, setup} from '../test_support'; + +function setupNodeModules(support: TestSupport): void { + const corePath = path.join(process.env.TEST_SRCDIR, 'angular/packages/core/npm_package'); + const commonPath = path.join(process.env.TEST_SRCDIR, 'angular/packages/common/npm_package'); + + const nodeModulesPath = path.join(support.basePath, 'node_modules'); + const angularCoreDirectory = path.join(nodeModulesPath, '@angular/core'); + const angularCommonDirectory = path.join(nodeModulesPath, '@angular/common'); + + // fs.symlinkSync(corePath, angularCoreDirectory); + // fs.symlinkSync(commonPath, angularCommonDirectory); +} + +describe('ngcc behavioral tests', () => { + if (!isInBazel()) { + // These tests should be excluded from the non-Bazel build. + return; + } + + let basePath: string; + let outDir: string; + let write: (fileName: string, content: string) => void; + let errorSpy: jasmine.Spy&((s: string) => void); + + function shouldExist(fileName: string) { + if (!fs.existsSync(path.resolve(outDir, fileName))) { + throw new Error(`Expected ${fileName} to be emitted (outDir: ${outDir})`); + } + } + + function shouldNotExist(fileName: string) { + if (fs.existsSync(path.resolve(outDir, fileName))) { + throw new Error(`Did not expect ${fileName} to be emitted (outDir: ${outDir})`); + } + } + + function getContents(fileName: string): string { + shouldExist(fileName); + const modulePath = path.resolve(outDir, fileName); + return fs.readFileSync(modulePath, 'utf8'); + } + + function writeConfig( + tsconfig: string = + '{"extends": "./tsconfig-base.json", "angularCompilerOptions": {"enableIvy": "ngtsc"}}') { + write('tsconfig.json', tsconfig); + } + + beforeEach(() => { + errorSpy = jasmine.createSpy('consoleError').and.callFake(console.error); + const support = setup(); + basePath = support.basePath; + outDir = path.join(basePath, 'built'); + process.chdir(basePath); + write = (fileName: string, content: string) => { support.write(fileName, content); }; + + setupNodeModules(support); + }); + + it('should run ngcc without errors', () => { + const nodeModulesPath = path.join(basePath, 'node_modules'); + console.error(nodeModulesPath); + const commonPath = path.join(nodeModulesPath, '@angular/common'); + const exitCode = mainNgcc([commonPath]); + + console.warn(find('node_modules_ngtsc').filter(p => p.endsWith('.js') || p.endsWith('map'))); + + console.warn(cat('node_modules_ngtsc/@angular/common/fesm2015/common.js').stdout); + console.warn(cat('node_modules_ngtsc/@angular/common/fesm2015/common.js.map').stdout); + + expect(exitCode).toBe(0); + }); +}); From 68b19b5eb2ace29e33801a4f4a262396dc0967aa Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 08:54:16 +0100 Subject: [PATCH 09/15] test(ivy): implement ngcc specific version of `makeProgram` --- .../src/ngcc/test/helpers/utils.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/compiler-cli/src/ngcc/test/helpers/utils.ts diff --git a/packages/compiler-cli/src/ngcc/test/helpers/utils.ts b/packages/compiler-cli/src/ngcc/test/helpers/utils.ts new file mode 100644 index 000000000000..4caa1e1d2e6b --- /dev/null +++ b/packages/compiler-cli/src/ngcc/test/helpers/utils.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; +import {makeProgram as _makeProgram} from '../../../ngtsc/testing/in_memory_typescript'; + +export {getDeclaration} from '../../../ngtsc/testing/in_memory_typescript'; + +export function makeProgram(...files: {name: string, contents: string}[]): ts.Program { + return _makeProgram([getFakeCore(), ...files], {allowJs: true, checkJs: false}).program; +} + +// TODO: unify this with the //packages/compiler-cli/test/ngtsc/fake_core package +export function getFakeCore() { + return { + name: 'node_modules/@angular/core/index.ts', + contents: ` + type FnWithArg = (arg?: any) => T; + + function callableClassDecorator(): FnWithArg<(clazz: any) => any> { + return null !; + } + + function callableParamDecorator(): FnWithArg<(a: any, b: any, c: any) => void> { + return null !; + } + + function makePropDecorator(): any { + } + + export const Component = callableClassDecorator(); + export const Directive = callableClassDecorator(); + export const Injectable = callableClassDecorator(); + export const NgModule = callableClassDecorator(); + + export const Input = makePropDecorator(); + + export const Inject = callableParamDecorator(); + export const Self = callableParamDecorator(); + export const SkipSelf = callableParamDecorator(); + export const Optional = callableParamDecorator(); + + export class InjectionToken { + constructor(name: string) {} + } + ` + }; +} From 02249b385d7f2360dc54c4fc9e22f09a65c32f84 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 08:51:14 +0100 Subject: [PATCH 10/15] feat(ivy): implement esm2015 and esm5 reflection hosts --- .../src/ngcc/src/host/esm2015_host.ts | 425 +++++++ .../src/ngcc/src/host/esm5_host.ts | 138 +++ .../src/ngcc/src/host/ngcc_host.ts | 16 + packages/compiler-cli/src/ngcc/src/utils.ts | 22 + .../src/ngcc/test/host/esm2015_host_spec.ts | 1020 ++++++++++++++++ .../src/ngcc/test/host/esm5_host_spec.ts | 1058 +++++++++++++++++ 6 files changed, 2679 insertions(+) create mode 100644 packages/compiler-cli/src/ngcc/src/host/esm2015_host.ts create mode 100644 packages/compiler-cli/src/ngcc/src/host/esm5_host.ts create mode 100644 packages/compiler-cli/src/ngcc/src/host/ngcc_host.ts create mode 100644 packages/compiler-cli/src/ngcc/src/utils.ts create mode 100644 packages/compiler-cli/src/ngcc/test/host/esm2015_host_spec.ts create mode 100644 packages/compiler-cli/src/ngcc/test/host/esm5_host_spec.ts diff --git a/packages/compiler-cli/src/ngcc/src/host/esm2015_host.ts b/packages/compiler-cli/src/ngcc/src/host/esm2015_host.ts new file mode 100644 index 000000000000..a2dff5d7df03 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/host/esm2015_host.ts @@ -0,0 +1,425 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; + +import {ClassMember, ClassMemberKind, Decorator, Parameter} from '../../../ngtsc/host'; +import {TypeScriptReflectionHost, reflectObjectLiteral} from '../../../ngtsc/metadata'; +import {getNameText} from '../utils'; +import {NgccReflectionHost} from './ngcc_host'; + +export const DECORATORS = 'decorators' as ts.__String; +export const PROP_DECORATORS = 'propDecorators' as ts.__String; +export const CONSTRUCTOR = '__constructor' as ts.__String; +export const CONSTRUCTOR_PARAMS = 'ctorParameters' as ts.__String; + +/** + * Esm2015 packages contain ECMAScript 2015 classes, etc. + * Decorators are defined via static properties on the class. For example: + * + * ``` + * class SomeDirective { + * } + * SomeDirective.decorators = [ + * { type: Directive, args: [{ selector: '[someDirective]' },] } + * ]; + * SomeDirective.ctorParameters = () => [ + * { type: ViewContainerRef, }, + * { type: TemplateRef, }, + * { type: undefined, decorators: [{ type: Inject, args: [INJECTED_TOKEN,] },] }, + * ]; + * SomeDirective.propDecorators = { + * "input1": [{ type: Input },], + * "input2": [{ type: Input },], + * }; + * ``` + * + * * Classes are decorated if they have a static property called `decorators`. + * * Members are decorated if there is a matching key on a static property + * called `propDecorators`. + * * Constructor parameters decorators are found on an object returned from + * a static method called `ctorParameters`. + */ +export class Esm2015ReflectionHost extends TypeScriptReflectionHost implements NgccReflectionHost { + constructor(checker: ts.TypeChecker) { super(checker); } + + /** + * Examine a declaration (for example, of a class or function) and return metadata about any + * decorators present on the declaration. + * + * @param declaration a TypeScript `ts.Declaration` node representing the class or function over + * which to reflect. For example, if the intent is to reflect the decorators of a class and the + * source is in ES6 format, this will be a `ts.ClassDeclaration` node. If the source is in ES5 + * format, this might be a `ts.VariableDeclaration` as classes in ES5 are represented as the + * result of an IIFE execution. + * + * @returns an array of `Decorator` metadata if decorators are present on the declaration, or + * `null` if either no decorators were present or if the declaration is not of a decoratable type. + */ + getDecoratorsOfDeclaration(declaration: ts.Declaration): Decorator[]|null { + const symbol = this.getClassSymbol(declaration); + if (symbol) { + if (symbol.exports && symbol.exports.has(DECORATORS)) { + // Symbol of the identifier for `SomeDirective.decorators`. + const decoratorsSymbol = symbol.exports.get(DECORATORS) !; + const decoratorsIdentifier = decoratorsSymbol.valueDeclaration; + + if (decoratorsIdentifier && decoratorsIdentifier.parent) { + if (ts.isBinaryExpression(decoratorsIdentifier.parent)) { + // AST of the array of decorator values + const decoratorsArray = decoratorsIdentifier.parent.right; + return this.reflectDecorators(decoratorsArray); + } + } + } + } + return null; + } + + /** + * Examine a declaration which should be of a class, and return metadata about the members of the + * class. + * + * @param declaration a TypeScript `ts.Declaration` node representing the class over which to + * reflect. If the source is in ES6 format, this will be a `ts.ClassDeclaration` node. If the + * source is in ES5 format, this might be a `ts.VariableDeclaration` as classes in ES5 are + * represented as the result of an IIFE execution. + * + * @returns an array of `ClassMember` metadata representing the members of the class. + * + * @throws if `declaration` does not resolve to a class declaration. + */ + getMembersOfClass(clazz: ts.Declaration): ClassMember[] { + const members: ClassMember[] = []; + const symbol = this.getClassSymbol(clazz); + if (!symbol) { + throw new Error(`Attempted to get members of a non-class: "${clazz.getText()}"`); + } + + // The decorators map contains all the properties that are decorated + const decoratorsMap = this.getMemberDecorators(symbol); + + // The member map contains all the method (instance and static); and any instance properties + // that are initialized in the class. + if (symbol.members) { + symbol.members.forEach((value, key) => { + const decorators = removeFromMap(decoratorsMap, key); + const member = this.reflectMember(value, decorators); + if (member) { + members.push(member); + } + }); + } + + // The static property map contains all the static properties + if (symbol.exports) { + symbol.exports.forEach((value, key) => { + const decorators = removeFromMap(decoratorsMap, key); + const member = this.reflectMember(value, decorators, true); + if (member) { + members.push(member); + } + }); + } + + // Deal with any decorated properties that were not initialized in the class + decoratorsMap.forEach((value, key) => { + members.push({ + implementation: null, + decorators: value, + isStatic: false, + kind: ClassMemberKind.Property, + name: key, + nameNode: null, + node: null, + type: null, + value: null + }); + }); + + return members; + } + + /** + * Reflect over the constructor of a class and return metadata about its parameters. + * + * This method only looks at the constructor of a class directly and not at any inherited + * constructors. + * + * @param declaration a TypeScript `ts.Declaration` node representing the class over which to + * reflect. If the source is in ES6 format, this will be a `ts.ClassDeclaration` node. If the + * source is in ES5 format, this might be a `ts.VariableDeclaration` as classes in ES5 are + * represented as the result of an IIFE execution. + * + * @returns an array of `Parameter` metadata representing the parameters of the constructor, if + * a constructor exists. If the constructor exists and has 0 parameters, this array will be empty. + * If the class has no constructor, this method returns `null`. + * + * @throws if `declaration` does not resolve to a class declaration. + */ + getConstructorParameters(clazz: ts.Declaration): Parameter[]|null { + const classSymbol = this.getClassSymbol(clazz); + if (!classSymbol) { + throw new Error( + `Attempted to get constructor parameters of a non-class: "${clazz.getText()}"`); + } + const parameterNodes = this.getConstructorParameterDeclarations(classSymbol); + if (parameterNodes) { + const parameters: Parameter[] = []; + const decoratorInfo = this.getConstructorDecorators(classSymbol); + parameterNodes.forEach((node, index) => { + const info = decoratorInfo[index]; + const decorators = + info && info.has('decorators') && this.reflectDecorators(info.get('decorators') !) || + null; + const type = info && info.get('type') || null; + const nameNode = node.name; + parameters.push({name: getNameText(nameNode), nameNode, type, decorators}); + }); + return parameters; + } + return null; + } + + /** + * Find a symbol for a declaration that we think is a class. + * @param declaration The declaration whose symbol we are finding + * @returns the symbol for the declaration or `undefined` if it is not + * a "class" or has no symbol. + */ + getClassSymbol(declaration: ts.Declaration): ts.Symbol|undefined { + return ts.isClassDeclaration(declaration) ? + declaration.name && this.checker.getSymbolAtLocation(declaration.name) : + undefined; + } + + /** + * Member decorators are declared as static properties of the class in ES2015: + * + * ``` + * SomeDirective.propDecorators = { + * "ngForOf": [{ type: Input },], + * "ngForTrackBy": [{ type: Input },], + * "ngForTemplate": [{ type: Input },], + * }; + * ``` + */ + protected getMemberDecorators(classSymbol: ts.Symbol): Map { + const memberDecorators = new Map(); + if (classSymbol.exports && classSymbol.exports.has(PROP_DECORATORS)) { + // Symbol of the identifier for `SomeDirective.propDecorators`. + const propDecoratorsMap = + getPropertyValueFromSymbol(classSymbol.exports.get(PROP_DECORATORS) !); + if (propDecoratorsMap && ts.isObjectLiteralExpression(propDecoratorsMap)) { + const propertiesMap = reflectObjectLiteral(propDecoratorsMap); + propertiesMap.forEach( + (value, name) => { memberDecorators.set(name, this.reflectDecorators(value)); }); + } + } + return memberDecorators; + } + + /** + * Reflect over the given expression and extract decorator information. + * @param decoratorsArray An expression that contains decorator information. + */ + protected reflectDecorators(decoratorsArray: ts.Expression): Decorator[] { + const decorators: Decorator[] = []; + + if (ts.isArrayLiteralExpression(decoratorsArray)) { + // Add each decorator that is imported from `@angular/core` into the `decorators` array + decoratorsArray.elements.forEach(node => { + + // If the decorator is not an object literal expression then we are not interested + if (ts.isObjectLiteralExpression(node)) { + // We are only interested in objects of the form: `{ type: DecoratorType, args: [...] }` + const decorator = reflectObjectLiteral(node); + + // Is the value of the `type` property an identifier? + const typeIdentifier = decorator.get('type'); + if (typeIdentifier && ts.isIdentifier(typeIdentifier)) { + decorators.push({ + name: typeIdentifier.text, + import: this.getImportOfIdentifier(typeIdentifier), node, + args: getDecoratorArgs(node), + }); + } + } + }); + } + return decorators; + } + + protected reflectMember(symbol: ts.Symbol, decorators?: Decorator[], isStatic?: boolean): + ClassMember|null { + let kind: ClassMemberKind|null = null; + let value: ts.Expression|null = null; + let name: string|null = null; + let nameNode: ts.Identifier|null = null; + let type = null; + + + const node = symbol.valueDeclaration || symbol.declarations && symbol.declarations[0]; + if (!node || !isClassMemberType(node)) { + return null; + } + + if (symbol.flags & ts.SymbolFlags.Method) { + kind = ClassMemberKind.Method; + } else if (symbol.flags & ts.SymbolFlags.Property) { + kind = ClassMemberKind.Property; + } else if (symbol.flags & ts.SymbolFlags.GetAccessor) { + kind = ClassMemberKind.Getter; + } else if (symbol.flags & ts.SymbolFlags.SetAccessor) { + kind = ClassMemberKind.Setter; + } + + if (isStatic && isPropertyAccess(node)) { + name = node.name.text; + value = symbol.flags & ts.SymbolFlags.Property ? node.parent.right : null; + } else if (isThisAssignment(node)) { + kind = ClassMemberKind.Property; + name = node.left.name.text; + value = node.right; + isStatic = false; + } else if (ts.isConstructorDeclaration(node)) { + kind = ClassMemberKind.Constructor; + name = 'constructor'; + isStatic = false; + } + + if (kind === null) { + console.warn(`Unknown member type: "${node.getText()}`); + return null; + } + + if (!name) { + if (isNamedDeclaration(node) && node.name && ts.isIdentifier(node.name)) { + name = node.name.text; + nameNode = node.name; + } else { + return null; + } + } + + // If we have still not determined if this is a static or instance member then + // look for the `static` keyword on the declaration + if (isStatic === undefined) { + isStatic = node.modifiers !== undefined && + node.modifiers.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword); + } + + return { + node, + implementation: node, kind, type, name, nameNode, value, isStatic, + decorators: decorators || [] + }; + } + + /** + * Find the declarations of the constructor parameters of a class identified by its symbol. + * @param classSymbol the class whose parameters we want to find. + * @returns an array of `ts.ParameterDeclaration` objects representing each of the parameters in + * the + * class's constructor or null if there is no constructor. + */ + protected getConstructorParameterDeclarations(classSymbol: ts.Symbol): + ts.ParameterDeclaration[]|null { + const constructorSymbol = classSymbol.members && classSymbol.members.get(CONSTRUCTOR); + if (constructorSymbol) { + // For some reason the constructor does not have a `valueDeclaration` ?!? + const constructor = constructorSymbol.declarations && + constructorSymbol.declarations[0] as ts.ConstructorDeclaration; + if (constructor && constructor.parameters) { + return Array.from(constructor.parameters); + } + return []; + } + return null; + } + + /** + * Constructors parameter decorators are declared in the body of static method of the class in + * ES2015: + * + * ``` + * SomeDirective.ctorParameters = () => [ + * { type: ViewContainerRef, }, + * { type: TemplateRef, }, + * { type: IterableDiffers, }, + * { type: undefined, decorators: [{ type: Inject, args: [INJECTED_TOKEN,] },] }, + * ]; + * ``` + */ + protected getConstructorDecorators(classSymbol: ts.Symbol): (Map|null)[] { + if (classSymbol.exports && classSymbol.exports.has(CONSTRUCTOR_PARAMS)) { + const paramDecoratorsProperty = + getPropertyValueFromSymbol(classSymbol.exports.get(CONSTRUCTOR_PARAMS) !); + if (paramDecoratorsProperty && ts.isArrowFunction(paramDecoratorsProperty)) { + if (ts.isArrayLiteralExpression(paramDecoratorsProperty.body)) { + return paramDecoratorsProperty.body.elements.map( + element => + ts.isObjectLiteralExpression(element) ? reflectObjectLiteral(element) : null); + } + } + } + return []; + } +} + +/** + * The arguments of a decorator are held in the `args` property of its declaration object. + */ +function getDecoratorArgs(node: ts.ObjectLiteralExpression): ts.Expression[] { + const argsProperty = node.properties.filter(ts.isPropertyAssignment) + .find(property => getNameText(property.name) === 'args'); + const argsExpression = argsProperty && argsProperty.initializer; + return argsExpression && ts.isArrayLiteralExpression(argsExpression) ? + Array.from(argsExpression.elements) : + []; +} + +/** + * Helper method to extract the value of a property given the property's "symbol", + * which is actually the symbol of the identifier of the property. + */ +export function getPropertyValueFromSymbol(propSymbol: ts.Symbol): ts.Expression|undefined { + const propIdentifier = propSymbol.valueDeclaration; + const parent = propIdentifier && propIdentifier.parent; + return parent && ts.isBinaryExpression(parent) ? parent.right : undefined; +} + +function removeFromMap(map: Map, key: ts.__String): T|undefined { + const mapKey = key as string; + const value = map.get(mapKey); + if (value !== undefined) { + map.delete(mapKey); + } + return value; +} + +function isPropertyAccess(node: ts.Node): node is ts.PropertyAccessExpression& + {parent: ts.BinaryExpression} { + return !!node.parent && ts.isBinaryExpression(node.parent) && ts.isPropertyAccessExpression(node); +} + +function isThisAssignment(node: ts.Declaration): node is ts.BinaryExpression& + {left: ts.PropertyAccessExpression} { + return ts.isBinaryExpression(node) && ts.isPropertyAccessExpression(node.left) && + node.left.expression.kind === ts.SyntaxKind.ThisKeyword; +} + +function isNamedDeclaration(node: ts.Declaration): node is ts.NamedDeclaration { + return !!(node as any).name; +} + + +function isClassMemberType(node: ts.Declaration): node is ts.ClassElement| + ts.PropertyAccessExpression|ts.BinaryExpression { + return ts.isClassElement(node) || isPropertyAccess(node) || ts.isBinaryExpression(node); +} \ No newline at end of file diff --git a/packages/compiler-cli/src/ngcc/src/host/esm5_host.ts b/packages/compiler-cli/src/ngcc/src/host/esm5_host.ts new file mode 100644 index 000000000000..a84bc471a76e --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/host/esm5_host.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; +import {Decorator} from '../../../ngtsc/host'; +import {ClassMember, ClassMemberKind} from '../../../ngtsc/host/src/reflection'; +import {reflectObjectLiteral} from '../../../ngtsc/metadata/src/reflector'; +import {CONSTRUCTOR_PARAMS, Esm2015ReflectionHost, getPropertyValueFromSymbol} from './esm2015_host'; + +/** + * ESM5 packages contain ECMAScript IIFE functions that act like classes. For example: + * + * ``` + * var CommonModule = (function () { + * function CommonModule() { + * } + * CommonModule.decorators = [ ... ]; + * ``` + * + * * "Classes" are decorated if they have a static property called `decorators`. + * * Members are decorated if there is a matching key on a static property + * called `propDecorators`. + * * Constructor parameters decorators are found on an object returned from + * a static method called `ctorParameters`. + * + */ +export class Esm5ReflectionHost extends Esm2015ReflectionHost { + constructor(checker: ts.TypeChecker) { super(checker); } + + /** + * Check whether the given declaration node actually represents a class. + */ + isClass(node: ts.Declaration): boolean { return !!this.getClassSymbol(node); } + + /** + * In ESM5 the implementation of a class is a function expression that is hidden inside an IIFE. + * So we need to dig around inside to get hold of the "class" symbol. + * @param declaration the top level declaration that represents an exported class. + */ + getClassSymbol(declaration: ts.Declaration): ts.Symbol|undefined { + if (ts.isVariableDeclaration(declaration)) { + const iifeBody = getIifeBody(declaration); + if (iifeBody) { + const innerClassIdentifier = getReturnIdentifier(iifeBody); + if (innerClassIdentifier) { + return this.checker.getSymbolAtLocation(innerClassIdentifier); + } + } + } + return undefined; + } + + /** + * Find the declarations of the constructor parameters of a class identified by its symbol. + * In ESM5 there is no "class" so the constructor that we want is actually the declaration + * function itself. + */ + protected getConstructorParameterDeclarations(classSymbol: ts.Symbol): ts.ParameterDeclaration[] { + const constructor = classSymbol.valueDeclaration as ts.FunctionDeclaration; + if (constructor && constructor.parameters) { + return Array.from(constructor.parameters); + } + return []; + } + + /** + * Constructors parameter decorators are declared in the body of static method of the constructor + * function in ES5. Note that unlike ESM2105 this is a function expression rather than an arrow + * function: + * + * ``` + * SomeDirective.ctorParameters = function() { return [ + * { type: ViewContainerRef, }, + * { type: TemplateRef, }, + * { type: IterableDiffers, }, + * { type: undefined, decorators: [{ type: Inject, args: [INJECTED_TOKEN,] },] }, + * ]; }; + * ``` + */ + protected getConstructorDecorators(classSymbol: ts.Symbol): (Map|null)[] { + const declaration = classSymbol.exports && classSymbol.exports.get(CONSTRUCTOR_PARAMS); + const paramDecoratorsProperty = declaration && getPropertyValueFromSymbol(declaration); + const returnStatement = getReturnStatement(paramDecoratorsProperty); + const expression = returnStatement && returnStatement.expression; + return expression && ts.isArrayLiteralExpression(expression) ? + expression.elements.map(reflectArrayElement) : + []; + } + + protected reflectMember(symbol: ts.Symbol, decorators?: Decorator[], isStatic?: boolean): + ClassMember|null { + const member = super.reflectMember(symbol, decorators, isStatic); + if (member && member.kind === ClassMemberKind.Method && member.isStatic && member.node && + ts.isPropertyAccessExpression(member.node) && member.node.parent && + ts.isBinaryExpression(member.node.parent) && + ts.isFunctionExpression(member.node.parent.right)) { + // Recompute the implementation for this member: + // ES5 static methods are variable declarations so the declaration is actually the + // initializer of the variable assignment + member.implementation = member.node.parent.right; + } + return member; + } +} + +function getIifeBody(declaration: ts.VariableDeclaration): ts.Block|undefined { + if (!declaration.initializer || !ts.isParenthesizedExpression(declaration.initializer)) { + return undefined; + } + const call = declaration.initializer; + return ts.isCallExpression(call.expression) && + ts.isFunctionExpression(call.expression.expression) ? + call.expression.expression.body : + undefined; +} + +function getReturnIdentifier(body: ts.Block): ts.Identifier|undefined { + const returnStatement = body.statements.find(ts.isReturnStatement); + return returnStatement && returnStatement.expression && + ts.isIdentifier(returnStatement.expression) ? + returnStatement.expression : + undefined; +} + +function getReturnStatement(declaration: ts.Expression | undefined): ts.ReturnStatement|undefined { + return declaration && ts.isFunctionExpression(declaration) ? + declaration.body.statements.find(ts.isReturnStatement) : + undefined; +} + +function reflectArrayElement(element: ts.Expression) { + return ts.isObjectLiteralExpression(element) ? reflectObjectLiteral(element) : null; +} \ No newline at end of file diff --git a/packages/compiler-cli/src/ngcc/src/host/ngcc_host.ts b/packages/compiler-cli/src/ngcc/src/host/ngcc_host.ts new file mode 100644 index 000000000000..ef32c719ed7c --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/host/ngcc_host.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; +import {ReflectionHost} from '../../../ngtsc/host'; + +/** + * A reflection host that has extra methods for looking at non-Typescript package formats + */ +export interface NgccReflectionHost extends ReflectionHost { + getClassSymbol(declaration: ts.Declaration): ts.Symbol|undefined; +} diff --git a/packages/compiler-cli/src/ngcc/src/utils.ts b/packages/compiler-cli/src/ngcc/src/utils.ts new file mode 100644 index 000000000000..425e6c9da718 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/utils.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; + +export function getOriginalSymbol(checker: ts.TypeChecker): (symbol: ts.Symbol) => ts.Symbol { + return function(symbol: ts.Symbol) { + return ts.SymbolFlags.Alias & symbol.flags ? checker.getAliasedSymbol(symbol) : symbol; + }; +} + +export function isDefined(value: T | undefined | null): value is T { + return !!value; +} + +export function getNameText(name: ts.PropertyName | ts.BindingName): string { + return ts.isIdentifier(name) || ts.isLiteralExpression(name) ? name.text : name.getText(); +} \ No newline at end of file diff --git a/packages/compiler-cli/src/ngcc/test/host/esm2015_host_spec.ts b/packages/compiler-cli/src/ngcc/test/host/esm2015_host_spec.ts new file mode 100644 index 000000000000..b30866ade3fe --- /dev/null +++ b/packages/compiler-cli/src/ngcc/test/host/esm2015_host_spec.ts @@ -0,0 +1,1020 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; +import {ClassMemberKind, Import} from '../../../ngtsc/host'; +import {Esm2015ReflectionHost} from '../../src/host/esm2015_host'; +import {getDeclaration, makeProgram} from '../helpers/utils'; + +const SOME_DIRECTIVE_FILE = { + name: '/some_directive.js', + contents: ` + import { Directive, Inject, InjectionToken, Input, HostListener, HostBinding } from '@angular/core'; + + const INJECTED_TOKEN = new InjectionToken('injected'); + const ViewContainerRef = {}; + const TemplateRef = {}; + + class SomeDirective { + constructor(_viewContainer, _template, injected) { + this.instanceProperty = 'instance'; + } + instanceMethod() {} + + onClick() {} + + @HostBinding('class.foo') + get isClassFoo() { return false; } + + static staticMethod() {} + } + SomeDirective.staticProperty = 'static'; + SomeDirective.decorators = [ + { type: Directive, args: [{ selector: '[someDirective]' },] } + ]; + SomeDirective.ctorParameters = () => [ + { type: ViewContainerRef, }, + { type: TemplateRef, }, + { type: undefined, decorators: [{ type: Inject, args: [INJECTED_TOKEN,] },] }, + ]; + SomeDirective.propDecorators = { + "input1": [{ type: Input },], + "input2": [{ type: Input },], + "target": [{ type: HostBinding, args: ['attr.target',] }, { type: Input },], + "onClick": [{ type: HostListener, args: ['click',] },], + }; + `, +}; + +const SIMPLE_CLASS_FILE = { + name: '/simple_class.js', + contents: ` + class EmptyClass {} + class NoDecoratorConstructorClass { + constructor(foo) {} + } + `, +}; + +const FOO_FUNCTION_FILE = { + name: '/foo_function.js', + contents: ` + import { Directive } from '@angular/core'; + + function foo() {} + foo.decorators = [ + { type: Directive, args: [{ selector: '[ignored]' },] } + ]; + `, +}; + +const INVALID_DECORATORS_FILE = { + name: '/invalid_decorators.js', + contents: ` + const NotArrayLiteralDecorator = {}; + class NotArrayLiteral { + } + NotArrayLiteral.decorators = () => [ + { type: NotArrayLiteralDecorator, args: [{ selector: '[ignored]' },] }, + ]; + + const NotObjectLiteralDecorator = {}; + class NotObjectLiteral { + } + NotObjectLiteral.decorators = [ + "This is not an object literal", + { type: NotObjectLiteralDecorator }, + ]; + + const NoTypePropertyDecorator1 = {}; + const NoTypePropertyDecorator2 = {}; + class NoTypeProperty { + } + NoTypeProperty.decorators = [ + { notType: NoTypePropertyDecorator1 }, + { type: NoTypePropertyDecorator2 }, + ]; + + const NotIdentifierDecorator = {}; + class NotIdentifier { + } + NotIdentifier.decorators = [ + { type: 'StringsLiteralsAreNotIdentifiers' }, + { type: NotIdentifierDecorator }, + ]; + `, +}; + +const INVALID_DECORATOR_ARGS_FILE = { + name: '/invalid_decorator_args.js', + contents: ` + const NoArgsPropertyDecorator = {}; + class NoArgsProperty { + } + NoArgsProperty.decorators = [ + { type: NoArgsPropertyDecorator }, + ]; + + const NoPropertyAssignmentDecorator = {}; + const args = [{ selector: '[ignored]' },]; + class NoPropertyAssignment { + } + NoPropertyAssignment.decorators = [ + { type: NoPropertyAssignmentDecorator, args }, + ]; + + const NotArrayLiteralDecorator = {}; + class NotArrayLiteral { + } + NotArrayLiteral.decorators = [ + { type: NotArrayLiteralDecorator, args: () => [{ selector: '[ignored]' },] }, + ]; + `, +}; + +const INVALID_PROP_DECORATORS_FILE = { + name: '/invalid_prop_decorators.js', + contents: ` + const NotObjectLiteralDecorator = {}; + class NotObjectLiteral { + } + NotObjectLiteral.propDecorators = () => ({ + "prop": [{ type: NotObjectLiteralDecorator },] + }); + + const NotObjectLiteralPropDecorator = {}; + class NotObjectLiteralProp { + } + NotObjectLiteralProp.propDecorators = { + "prop": [ + "This is not an object literal", + { type: NotObjectLiteralPropDecorator }, + ] + }; + + const NoTypePropertyDecorator1 = {}; + const NoTypePropertyDecorator2 = {}; + class NoTypeProperty { + } + NoTypeProperty.propDecorators = { + "prop": [ + { notType: NoTypePropertyDecorator1 }, + { type: NoTypePropertyDecorator2 }, + ] + }; + + const NotIdentifierDecorator = {}; + class NotIdentifier { + } + NotIdentifier.propDecorators = { + "prop": [ + { type: 'StringsLiteralsAreNotIdentifiers' }, + { type: NotIdentifierDecorator }, + ] + }; + `, +}; + +const INVALID_PROP_DECORATOR_ARGS_FILE = { + name: '/invalid_prop_decorator_args.js', + contents: ` + const NoArgsPropertyDecorator = {}; + class NoArgsProperty { + } + NoArgsProperty.propDecorators = { + "prop": [{ type: NoArgsPropertyDecorator },] + }; + + const NoPropertyAssignmentDecorator = {}; + const args = [{ selector: '[ignored]' },]; + class NoPropertyAssignment { + } + NoPropertyAssignment.propDecorators = { + "prop": [{ type: NoPropertyAssignmentDecorator, args },] + }; + + const NotArrayLiteralDecorator = {}; + class NotArrayLiteral { + } + NotArrayLiteral.propDecorators = { + "prop": [{ type: NotArrayLiteralDecorator, args: () => [{ selector: '[ignored]' },] },], + }; + `, +}; + +const INVALID_CTOR_DECORATORS_FILE = { + name: '/invalid_ctor_decorators.js', + contents: ` + const NoParametersDecorator = {}; + class NoParameters { + constructor() { + } + } + + const NotArrowFunctionDecorator = {}; + class NotArrowFunction { + constructor(arg1) { + } + } + NotArrowFunction.ctorParameters = function() { + return { type: 'ParamType', decorators: [{ type: NotArrowFunctionDecorator },] }; + }; + + const NotArrayLiteralDecorator = {}; + class NotArrayLiteral { + constructor(arg1) { + } + } + NotArrayLiteral.ctorParameters = () => 'StringsAreNotArrayLiterals'; + + const NotObjectLiteralDecorator = {}; + class NotObjectLiteral { + constructor(arg1, arg2) { + } + } + NotObjectLiteral.ctorParameters = () => [ + "This is not an object literal", + { type: 'ParamType', decorators: [{ type: NotObjectLiteralDecorator },] }, + ]; + + const NoTypePropertyDecorator1 = {}; + const NoTypePropertyDecorator2 = {}; + class NoTypeProperty { + constructor(arg1, arg2) { + } + } + NoTypeProperty.ctorParameters = () => [ + { + type: 'ParamType', + decorators: [ + { notType: NoTypePropertyDecorator1 }, + { type: NoTypePropertyDecorator2 }, + ] + }, + ]; + + const NotIdentifierDecorator = {}; + class NotIdentifier { + constructor(arg1, arg2) { + } + } + NotIdentifier.ctorParameters = () => [ + { + type: 'ParamType', + decorators: [ + { type: 'StringsLiteralsAreNotIdentifiers' }, + { type: NotIdentifierDecorator }, + ] + }, + ]; + `, +}; + +const INVALID_CTOR_DECORATOR_ARGS_FILE = { + name: '/invalid_ctor_decorator_args.js', + contents: ` + const NoArgsPropertyDecorator = {}; + class NoArgsProperty { + constructor(arg1) { + } + } + NoArgsProperty.ctorParameters = () => [ + { type: 'ParamType', decorators: [{ type: NoArgsPropertyDecorator },] }, + ]; + + const NoPropertyAssignmentDecorator = {}; + const args = [{ selector: '[ignored]' },]; + class NoPropertyAssignment { + constructor(arg1) { + } + } + NoPropertyAssignment.ctorParameters = () => [ + { type: 'ParamType', decorators: [{ type: NoPropertyAssignmentDecorator, args },] }, + ]; + + const NotArrayLiteralDecorator = {}; + class NotArrayLiteral { + constructor(arg1) { + } + } + NotArrayLiteral.ctorParameters = () => [ + { type: 'ParamType', decorators: [{ type: NotArrayLiteralDecorator, args: () => [{ selector: '[ignored]' },] },] }, + ]; + `, +}; + +const IMPORTS_FILES = [ + { + name: '/a.js', + contents: ` + export const a = 'a'; + `, + }, + { + name: '/b.js', + contents: ` + import {a} from './a.js'; + import {a as foo} from './a.js'; + + const b = a; + const c = foo; + const d = b; + `, + }, +]; + +const EXPORTS_FILES = [ + { + name: '/a.js', + contents: ` + export const a = 'a'; + `, + }, + { + name: '/b.js', + contents: ` + import {Directive} from '@angular/core'; + import {a} from './a'; + import {a as foo} from './a'; + export {Directive} from '@angular/core'; + export {a} from './a'; + export const b = a; + export const c = foo; + export const d = b; + export const e = 'e'; + export const DirectiveX = Directive; + export class SomeClass {} + `, + }, +]; + +describe('Esm2015ReflectionHost', () => { + + describe('getDecoratorsOfDeclaration()', () => { + it('should find the decorators on a class', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isClassDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators).toBeDefined(); + expect(decorators.length).toEqual(1); + + const decorator = decorators[0]; + expect(decorator.name).toEqual('Directive'); + expect(decorator.import).toEqual({name: 'Directive', from: '@angular/core'}); + expect(decorator.args !.map(arg => arg.getText())).toEqual([ + '{ selector: \'[someDirective]\' }', + ]); + }); + + it('should return null if the symbol is not a class', () => { + const program = makeProgram(FOO_FUNCTION_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const functionNode = + getDeclaration(program, FOO_FUNCTION_FILE.name, 'foo', ts.isFunctionDeclaration); + const decorators = host.getDecoratorsOfDeclaration(functionNode); + expect(decorators).toBe(null); + }); + + it('should return null if there are no decorators', () => { + const program = makeProgram(SIMPLE_CLASS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SIMPLE_CLASS_FILE.name, 'EmptyClass', ts.isClassDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode); + expect(decorators).toBe(null); + }); + + it('should ignore `decorators` if it is not an array literal', () => { + const program = makeProgram(INVALID_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATORS_FILE.name, 'NotArrayLiteral', ts.isClassDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode); + expect(decorators).toEqual([]); + }); + + it('should ignore decorator elements that are not object literals', () => { + const program = makeProgram(INVALID_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATORS_FILE.name, 'NotObjectLiteral', ts.isClassDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NotObjectLiteralDecorator'})); + }); + + it('should ignore decorator elements that have no `type` property', () => { + const program = makeProgram(INVALID_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATORS_FILE.name, 'NoTypeProperty', ts.isClassDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NoTypePropertyDecorator2'})); + }); + + it('should ignore decorator elements whose `type` value is not an identifier', () => { + const program = makeProgram(INVALID_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATORS_FILE.name, 'NotIdentifier', ts.isClassDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NotIdentifierDecorator'})); + }); + + it('should use `getImportOfIdentifier()` to retrieve import info', () => { + const mockImportInfo = {} as Import; + const spy = spyOn(Esm2015ReflectionHost.prototype, 'getImportOfIdentifier') + .and.returnValue(mockImportInfo); + + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isClassDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toEqual(1); + expect(decorators[0].import).toBe(mockImportInfo); + + const typeIdentifier = spy.calls.mostRecent().args[0] as ts.Identifier; + expect(typeIdentifier.text).toBe('Directive'); + }); + + describe('(returned decorators `args`)', () => { + it('should be an empty array if decorator has no `args` property', () => { + const program = makeProgram(INVALID_DECORATOR_ARGS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATOR_ARGS_FILE.name, 'NoArgsProperty', ts.isClassDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NoArgsPropertyDecorator'); + expect(decorators[0].args).toEqual([]); + }); + + it('should be an empty array if decorator\'s `args` has no property assignment', () => { + const program = makeProgram(INVALID_DECORATOR_ARGS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATOR_ARGS_FILE.name, 'NoPropertyAssignment', + ts.isClassDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NoPropertyAssignmentDecorator'); + expect(decorators[0].args).toEqual([]); + }); + + it('should be an empty array if `args` property value is not an array literal', () => { + const program = makeProgram(INVALID_DECORATOR_ARGS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATOR_ARGS_FILE.name, 'NotArrayLiteral', ts.isClassDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NotArrayLiteralDecorator'); + expect(decorators[0].args).toEqual([]); + }); + }); + }); + + describe('getMembersOfClass()', () => { + it('should find decorated properties on a class', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isClassDeclaration); + const members = host.getMembersOfClass(classNode); + + const input1 = members.find(member => member.name === 'input1') !; + expect(input1.kind).toEqual(ClassMemberKind.Property); + expect(input1.isStatic).toEqual(false); + expect(input1.decorators !.map(d => d.name)).toEqual(['Input']); + + const input2 = members.find(member => member.name === 'input2') !; + expect(input2.kind).toEqual(ClassMemberKind.Property); + expect(input2.isStatic).toEqual(false); + expect(input1.decorators !.map(d => d.name)).toEqual(['Input']); + }); + + it('should find non decorated properties on a class', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isClassDeclaration); + const members = host.getMembersOfClass(classNode); + + const instanceProperty = members.find(member => member.name === 'instanceProperty') !; + expect(instanceProperty.kind).toEqual(ClassMemberKind.Property); + expect(instanceProperty.isStatic).toEqual(false); + expect(ts.isBinaryExpression(instanceProperty.implementation !)).toEqual(true); + expect(instanceProperty.value !.getText()).toEqual(`'instance'`); + }); + + it('should find static methods on a class', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isClassDeclaration); + debugger; + const members = host.getMembersOfClass(classNode); + + const staticMethod = members.find(member => member.name === 'staticMethod') !; + expect(staticMethod.kind).toEqual(ClassMemberKind.Method); + expect(staticMethod.isStatic).toEqual(true); + expect(ts.isMethodDeclaration(staticMethod.implementation !)).toEqual(true); + }); + + it('should find static properties on a class', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isClassDeclaration); + const members = host.getMembersOfClass(classNode); + + const staticProperty = members.find(member => member.name === 'staticProperty') !; + expect(staticProperty.kind).toEqual(ClassMemberKind.Property); + expect(staticProperty.isStatic).toEqual(true); + expect(ts.isPropertyAccessExpression(staticProperty.implementation !)).toEqual(true); + expect(staticProperty.value !.getText()).toEqual(`'static'`); + }); + + it('should throw if the symbol is not a class', () => { + const program = makeProgram(FOO_FUNCTION_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const functionNode = + getDeclaration(program, FOO_FUNCTION_FILE.name, 'foo', ts.isFunctionDeclaration); + expect(() => { + host.getMembersOfClass(functionNode); + }).toThrowError(`Attempted to get members of a non-class: "function foo() {}"`); + }); + + it('should return an empty array if there are no prop decorators', () => { + const program = makeProgram(SIMPLE_CLASS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SIMPLE_CLASS_FILE.name, 'EmptyClass', ts.isClassDeclaration); + const members = host.getMembersOfClass(classNode); + + expect(members).toEqual([]); + }); + + it('should not process decorated properties in `propDecorators` if it is not an object literal', + () => { + const program = makeProgram(INVALID_PROP_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATORS_FILE.name, 'NotObjectLiteral', ts.isClassDeclaration); + const members = host.getMembersOfClass(classNode); + + expect(members.map(member => member.name)).not.toContain('prop'); + }); + + it('should ignore prop decorator elements that are not object literals', () => { + const program = makeProgram(INVALID_PROP_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATORS_FILE.name, 'NotObjectLiteralProp', + ts.isClassDeclaration); + const members = host.getMembersOfClass(classNode); + const prop = members.find(m => m.name === 'prop') !; + const decorators = prop.decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({ + name: 'NotObjectLiteralPropDecorator' + })); + }); + + it('should ignore prop decorator elements that have no `type` property', () => { + const program = makeProgram(INVALID_PROP_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATORS_FILE.name, 'NoTypeProperty', ts.isClassDeclaration); + const members = host.getMembersOfClass(classNode); + const prop = members.find(m => m.name === 'prop') !; + const decorators = prop.decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NoTypePropertyDecorator2'})); + }); + + it('should ignore prop decorator elements whose `type` value is not an identifier', () => { + const program = makeProgram(INVALID_PROP_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATORS_FILE.name, 'NotIdentifier', ts.isClassDeclaration); + const members = host.getMembersOfClass(classNode); + const prop = members.find(m => m.name === 'prop') !; + const decorators = prop.decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NotIdentifierDecorator'})); + }); + + it('should use `getImportOfIdentifier()` to retrieve import info', () => { + let callCount = 0; + const spy = + spyOn(Esm2015ReflectionHost.prototype, 'getImportOfIdentifier').and.callFake(() => { + callCount++; + return {name: `name${callCount}`, from: `from${callCount}`}; + }); + + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isClassDeclaration); + const members = host.getMembersOfClass(classNode); + + expect(spy).toHaveBeenCalled(); + expect(spy.calls.allArgs().map(arg => arg[0].getText())).toEqual([ + 'Input', + 'Input', + 'HostBinding', + 'Input', + 'HostListener', + ]); + + const index = members.findIndex(member => member.name === 'input1'); + expect(members[index].decorators !.length).toBe(1); + expect(members[index].decorators ![0].import).toEqual({name: 'name1', from: 'from1'}); + }); + + describe('(returned prop decorators `args`)', () => { + it('should be an empty array if prop decorator has no `args` property', () => { + const program = makeProgram(INVALID_PROP_DECORATOR_ARGS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATOR_ARGS_FILE.name, 'NoArgsProperty', + ts.isClassDeclaration); + const members = host.getMembersOfClass(classNode); + const prop = members.find(m => m.name === 'prop') !; + const decorators = prop.decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NoArgsPropertyDecorator'); + expect(decorators[0].args).toEqual([]); + }); + + it('should be an empty array if prop decorator\'s `args` has no property assignment', () => { + const program = makeProgram(INVALID_PROP_DECORATOR_ARGS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATOR_ARGS_FILE.name, 'NoPropertyAssignment', + ts.isClassDeclaration); + const members = host.getMembersOfClass(classNode); + const prop = members.find(m => m.name === 'prop') !; + const decorators = prop.decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NoPropertyAssignmentDecorator'); + expect(decorators[0].args).toEqual([]); + }); + + it('should be an empty array if `args` property value is not an array literal', () => { + const program = makeProgram(INVALID_PROP_DECORATOR_ARGS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATOR_ARGS_FILE.name, 'NotArrayLiteral', + ts.isClassDeclaration); + const members = host.getMembersOfClass(classNode); + const prop = members.find(m => m.name === 'prop') !; + const decorators = prop.decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NotArrayLiteralDecorator'); + expect(decorators[0].args).toEqual([]); + }); + }); + }); + + describe('getConstructorParameters', () => { + it('should find the decorated constructor parameters', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + + expect(parameters).toBeDefined(); + expect(parameters !.map(parameter => parameter.name)).toEqual([ + '_viewContainer', '_template', 'injected' + ]); + expect(parameters !.map(parameter => parameter.type !.getText())).toEqual([ + 'ViewContainerRef', 'TemplateRef', 'undefined' + ]); + }); + + it('should throw if the symbol is not a class', () => { + const program = makeProgram(FOO_FUNCTION_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const functionNode = + getDeclaration(program, FOO_FUNCTION_FILE.name, 'foo', ts.isFunctionDeclaration); + expect(() => { host.getConstructorParameters(functionNode); }) + .toThrowError( + 'Attempted to get constructor parameters of a non-class: "function foo() {}"'); + }); + + it('should return `null` if there is no constructor', () => { + const program = makeProgram(SIMPLE_CLASS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SIMPLE_CLASS_FILE.name, 'EmptyClass', ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + expect(parameters).toBe(null); + }); + + it('should return an array even if there are no decorators', () => { + const program = makeProgram(SIMPLE_CLASS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SIMPLE_CLASS_FILE.name, 'NoDecoratorConstructorClass', ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + + expect(parameters).toEqual(jasmine.any(Array)); + expect(parameters !.length).toEqual(1); + expect(parameters ![0].name).toEqual('foo'); + expect(parameters ![0].decorators).toBe(null); + }); + + it('should return an empty array if there are no constructor parameters', () => { + const program = makeProgram(INVALID_CTOR_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATORS_FILE.name, 'NoParameters', ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + + expect(parameters).toEqual([]); + }); + + it('should ignore `ctorParameters` if it is not an arrow function', () => { + const program = makeProgram(INVALID_CTOR_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATORS_FILE.name, 'NotArrowFunction', ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + + expect(parameters !.length).toBe(1); + expect(parameters ![0]).toEqual(jasmine.objectContaining({ + name: 'arg1', + decorators: null, + })); + }); + + it('should ignore `ctorParameters` if it does not return an array literal', () => { + const program = makeProgram(INVALID_CTOR_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATORS_FILE.name, 'NotArrayLiteral', ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + + expect(parameters !.length).toBe(1); + expect(parameters ![0]).toEqual(jasmine.objectContaining({ + name: 'arg1', + decorators: null, + })); + }); + + describe('(returned parameters `decorators`)', () => { + it('should ignore param decorator elements that are not object literals', () => { + const program = makeProgram(INVALID_CTOR_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATORS_FILE.name, 'NotObjectLiteral', ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + + expect(parameters !.length).toBe(2); + expect(parameters ![0]).toEqual(jasmine.objectContaining({ + name: 'arg1', + decorators: null, + })); + expect(parameters ![1]).toEqual(jasmine.objectContaining({ + name: 'arg2', + decorators: jasmine.any(Array) as any + })); + }); + + it('should ignore param decorator elements that have no `type` property', () => { + const program = makeProgram(INVALID_CTOR_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATORS_FILE.name, 'NoTypeProperty', ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + const decorators = parameters ![0].decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NoTypePropertyDecorator2'})); + }); + + it('should ignore param decorator elements whose `type` value is not an identifier', () => { + const program = makeProgram(INVALID_CTOR_DECORATORS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATORS_FILE.name, 'NotIdentifier', ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + const decorators = parameters ![0].decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NotIdentifierDecorator'})); + }); + + it('should use `getImportOfIdentifier()` to retrieve import info', () => { + const mockImportInfo = {} as Import; + const spy = spyOn(Esm2015ReflectionHost.prototype, 'getImportOfIdentifier') + .and.returnValue(mockImportInfo); + + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + const decorators = parameters ![2].decorators !; + + expect(decorators.length).toEqual(1); + expect(decorators[0].import).toBe(mockImportInfo); + + const typeIdentifier = spy.calls.mostRecent().args[0] as ts.Identifier; + expect(typeIdentifier.text).toBe('Inject'); + }); + }); + + describe('(returned parameters `decorators.args`)', () => { + it('should be an empty array if param decorator has no `args` property', () => { + const program = makeProgram(INVALID_CTOR_DECORATOR_ARGS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATOR_ARGS_FILE.name, 'NoArgsProperty', + ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + expect(parameters !.length).toBe(1); + const decorators = parameters ![0].decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NoArgsPropertyDecorator'); + expect(decorators[0].args).toEqual([]); + }); + + it('should be an empty array if param decorator\'s `args` has no property assignment', () => { + const program = makeProgram(INVALID_CTOR_DECORATOR_ARGS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATOR_ARGS_FILE.name, 'NoPropertyAssignment', + ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + const decorators = parameters ![0].decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NoPropertyAssignmentDecorator'); + expect(decorators[0].args).toEqual([]); + }); + + it('should be an empty array if `args` property value is not an array literal', () => { + const program = makeProgram(INVALID_CTOR_DECORATOR_ARGS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATOR_ARGS_FILE.name, 'NotArrayLiteral', + ts.isClassDeclaration); + const parameters = host.getConstructorParameters(classNode); + const decorators = parameters ![0].decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NotArrayLiteralDecorator'); + expect(decorators[0].args).toEqual([]); + }); + }); + }); + + describe('getImportOfIdentifier', () => { + it('should find the import of an identifier', () => { + const program = makeProgram(...IMPORTS_FILES); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const variableNode = + getDeclaration(program, IMPORTS_FILES[1].name, 'b', ts.isVariableDeclaration); + const importOfIdent = host.getImportOfIdentifier(variableNode.initializer as ts.Identifier); + + expect(importOfIdent).toEqual({name: 'a', from: './a.js'}); + }); + + it('should find the name by which the identifier was exported, not imported', () => { + const program = makeProgram(...IMPORTS_FILES); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const variableNode = + getDeclaration(program, IMPORTS_FILES[1].name, 'c', ts.isVariableDeclaration); + const importOfIdent = host.getImportOfIdentifier(variableNode.initializer as ts.Identifier); + + expect(importOfIdent).toEqual({name: 'a', from: './a.js'}); + }); + + it('should return null if the identifier was not imported', () => { + const program = makeProgram(...IMPORTS_FILES); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const variableNode = + getDeclaration(program, IMPORTS_FILES[1].name, 'd', ts.isVariableDeclaration); + const importOfIdent = host.getImportOfIdentifier(variableNode.initializer as ts.Identifier); + + expect(importOfIdent).toBeNull(); + }); + }); + + describe('getDeclarationOfIdentifier', () => { + it('should return the declaration of a locally defined identifier', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isClassDeclaration); + const ctrDecorators = host.getConstructorParameters(classNode) !; + const identifierOfViewContainerRef = ctrDecorators[0].type !as ts.Identifier; + + const expectedDeclarationNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'ViewContainerRef', ts.isVariableDeclaration); + const actualDeclaration = host.getDeclarationOfIdentifier(identifierOfViewContainerRef); + expect(actualDeclaration).not.toBe(null); + expect(actualDeclaration !.node).toBe(expectedDeclarationNode); + expect(actualDeclaration !.viaModule).toBe(null); + }); + + it('should return the declaration of an externally defined identifier', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isClassDeclaration); + const classDecorators = host.getDecoratorsOfDeclaration(classNode) !; + const identifierOfDirective = ((classDecorators[0].node as ts.ObjectLiteralExpression) + .properties[0] as ts.PropertyAssignment) + .initializer as ts.Identifier; + + const expectedDeclarationNode = getDeclaration( + program, 'node_modules/@angular/core/index.ts', 'Directive', ts.isVariableDeclaration); + const actualDeclaration = host.getDeclarationOfIdentifier(identifierOfDirective); + expect(actualDeclaration).not.toBe(null); + expect(actualDeclaration !.node).toBe(expectedDeclarationNode); + expect(actualDeclaration !.viaModule).toBe('@angular/core'); + }); + }); + + describe('getExportsOfModule()', () => { + it('should return a map of all the exports from a given module', () => { + const program = makeProgram(...EXPORTS_FILES); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const file = program.getSourceFile(EXPORTS_FILES[1].name) !; + const exportDeclarations = host.getExportsOfModule(file); + expect(exportDeclarations).not.toBe(null); + expect(Array.from(exportDeclarations !.keys())).toEqual([ + 'Directive', + 'a', + 'b', + 'c', + 'd', + 'e', + 'DirectiveX', + 'SomeClass', + ]); + + const values = Array.from(exportDeclarations !.values()) + .map(declaration => [declaration.node.getText(), declaration.viaModule]); + expect(values).toEqual([ + // TODO clarify what is expected here... + // [`Directive = callableClassDecorator()`, '@angular/core'], + [`Directive = callableClassDecorator()`, null], + [`a = 'a'`, null], + [`b = a`, null], + [`c = foo`, null], + [`d = b`, null], + [`e = 'e'`, null], + [`DirectiveX = Directive`, null], + ['export class SomeClass {}', null], + ]); + }); + }); + + describe('isClass()', () => { + it('should return true if a given node is a TS class declaration', () => { + const program = makeProgram(SIMPLE_CLASS_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const node = + getDeclaration(program, SIMPLE_CLASS_FILE.name, 'EmptyClass', ts.isClassDeclaration); + expect(host.isClass(node)).toBe(true); + }); + + it('should return false if a given node is a TS function declaration', () => { + const program = makeProgram(FOO_FUNCTION_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const node = getDeclaration(program, FOO_FUNCTION_FILE.name, 'foo', ts.isFunctionDeclaration); + expect(host.isClass(node)).toBe(false); + }); + }); +}); diff --git a/packages/compiler-cli/src/ngcc/test/host/esm5_host_spec.ts b/packages/compiler-cli/src/ngcc/test/host/esm5_host_spec.ts new file mode 100644 index 000000000000..92b9ab0719b6 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/test/host/esm5_host_spec.ts @@ -0,0 +1,1058 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; +import {ClassMemberKind, Import} from '../../../ngtsc/host'; +import {Esm5ReflectionHost} from '../../src/host/esm5_host'; +import {getDeclaration, makeProgram} from '../helpers/utils'; + +const SOME_DIRECTIVE_FILE = { + name: '/some_directive.js', + contents: ` + import { Directive, Inject, InjectionToken, Input } from '@angular/core'; + + var INJECTED_TOKEN = new InjectionToken('injected'); + var ViewContainerRef = {}; + var TemplateRef = {}; + + var SomeDirective = (function() { + function SomeDirective(_viewContainer, _template, injected) { + this.instanceProperty = 'instance'; + } + SomeDirective.prototype = { + instanceMethod: function() {}, + }; + SomeDirective.staticMethod = function() {}; + SomeDirective.staticProperty = 'static'; + SomeDirective.decorators = [ + { type: Directive, args: [{ selector: '[someDirective]' },] } + ]; + SomeDirective.ctorParameters = function() { return [ + { type: ViewContainerRef, }, + { type: TemplateRef, }, + { type: undefined, decorators: [{ type: Inject, args: [INJECTED_TOKEN,] },] }, + ]; }; + SomeDirective.propDecorators = { + "input1": [{ type: Input },], + "input2": [{ type: Input },], + }; + return SomeDirective; + }()); + `, +}; + +const SIMPLE_CLASS_FILE = { + name: '/simple_class.js', + contents: ` + var EmptyClass = (function() { + function EmptyClass() {} + return EmptyClass; + }()); + var NoDecoratorConstructorClass = (function() { + function NoDecoratorConstructorClass(foo) { + } + return NoDecoratorConstructorClass; + }()); + `, +}; + +const FOO_FUNCTION_FILE = { + name: '/foo_function.js', + contents: ` + import { Directive } from '@angular/core'; + + function foo() {} + foo.decorators = [ + { type: Directive, args: [{ selector: '[ignored]' },] } + ]; + `, +}; + +const INVALID_DECORATORS_FILE = { + name: '/invalid_decorators.js', + contents: ` + var NotArrayLiteralDecorator = {}; + var NotArrayLiteral = (function() { + function NotArrayLiteral() { + } + NotArrayLiteral.decorators = () => [ + { type: NotArrayLiteralDecorator, args: [{ selector: '[ignored]' },] }, + ]; + return NotArrayLiteral; + }()); + + var NotObjectLiteralDecorator = {}; + var NotObjectLiteral = (function() { + function NotObjectLiteral() { + } + NotObjectLiteral.decorators = [ + "This is not an object literal", + { type: NotObjectLiteralDecorator }, + ]; + return NotObjectLiteral; + }()); + + var NoTypePropertyDecorator1 = {}; + var NoTypePropertyDecorator2 = {}; + var NoTypeProperty = (function() { + function NoTypeProperty() { + } + NoTypeProperty.decorators = [ + { notType: NoTypePropertyDecorator1 }, + { type: NoTypePropertyDecorator2 }, + ]; + return NoTypeProperty; + }()); + + var NotIdentifierDecorator = {}; + var NotIdentifier = (function() { + function NotIdentifier() { + } + NotIdentifier.decorators = [ + { type: 'StringsLiteralsAreNotIdentifiers' }, + { type: NotIdentifierDecorator }, + ]; + return NotIdentifier; + }()); + `, +}; + +const INVALID_DECORATOR_ARGS_FILE = { + name: '/invalid_decorator_args.js', + contents: ` + var NoArgsPropertyDecorator = {}; + var NoArgsProperty = (function() { + function NoArgsProperty() { + } + NoArgsProperty.decorators = [ + { type: NoArgsPropertyDecorator }, + ]; + return NoArgsProperty; + }()); + + var NoPropertyAssignmentDecorator = {}; + var args = [{ selector: '[ignored]' },]; + var NoPropertyAssignment = (function() { + function NoPropertyAssignment() { + } + NoPropertyAssignment.decorators = [ + { type: NoPropertyAssignmentDecorator, args }, + ]; + return NoPropertyAssignment; + }()); + + var NotArrayLiteralDecorator = {}; + var NotArrayLiteral = (function() { + function NotArrayLiteral() { + } + NotArrayLiteral.decorators = [ + { type: NotArrayLiteralDecorator, args: () => [{ selector: '[ignored]' },] }, + ]; + return NotArrayLiteral; + }()); + `, +}; + +const INVALID_PROP_DECORATORS_FILE = { + name: '/invalid_prop_decorators.js', + contents: ` + var NotObjectLiteralDecorator = {}; + var NotObjectLiteral = (function() { + function NotObjectLiteral() { + } + NotObjectLiteral.propDecorators = () => ({ + "prop": [{ type: NotObjectLiteralDecorator },] + }); + return NotObjectLiteral; + }()); + + var NotObjectLiteralPropDecorator = {}; + var NotObjectLiteralProp = (function() { + function NotObjectLiteralProp() { + } + NotObjectLiteralProp.propDecorators = { + "prop": [ + "This is not an object literal", + { type: NotObjectLiteralPropDecorator }, + ] + }; + return NotObjectLiteralProp; + }()); + + var NoTypePropertyDecorator1 = {}; + var NoTypePropertyDecorator2 = {}; + var NoTypeProperty = (function() { + function NoTypeProperty() { + } + NoTypeProperty.propDecorators = { + "prop": [ + { notType: NoTypePropertyDecorator1 }, + { type: NoTypePropertyDecorator2 }, + ] + }; + return NoTypeProperty; + }()); + + var NotIdentifierDecorator = {}; + var NotIdentifier = (function() { + function NotIdentifier() { + } + NotIdentifier.propDecorators = { + "prop": [ + { type: 'StringsLiteralsAreNotIdentifiers' }, + { type: NotIdentifierDecorator }, + ] + }; + return NotIdentifier; + }()); + `, +}; + +const INVALID_PROP_DECORATOR_ARGS_FILE = { + name: '/invalid_prop_decorator_args.js', + contents: ` + var NoArgsPropertyDecorator = {}; + var NoArgsProperty = (function() { + function NoArgsProperty() { + } + NoArgsProperty.propDecorators = { + "prop": [{ type: NoArgsPropertyDecorator },] + }; + return NoArgsProperty; + }()); + + var NoPropertyAssignmentDecorator = {}; + var args = [{ selector: '[ignored]' },]; + var NoPropertyAssignment = (function() { + function NoPropertyAssignment() { + } + NoPropertyAssignment.propDecorators = { + "prop": [{ type: NoPropertyAssignmentDecorator, args },] + }; + return NoPropertyAssignment; + }()); + + var NotArrayLiteralDecorator = {}; + var NotArrayLiteral = (function() { + function NotArrayLiteral() { + } + NotArrayLiteral.propDecorators = { + "prop": [{ type: NotArrayLiteralDecorator, args: () => [{ selector: '[ignored]' },] },], + }; + return NotArrayLiteral; + }()); + `, +}; + +const INVALID_CTOR_DECORATORS_FILE = { + name: '/invalid_ctor_decorators.js', + contents: ` + var NoParametersDecorator = {}; + var NoParameters = (function() { + function NoParameters() {} + return NoParameters; + }()); + + var ArrowFunctionDecorator = {}; + var ArrowFunction = (function() { + function ArrowFunction(arg1) { + } + ArrowFunction.ctorParameters = () => [ + { type: 'ParamType', decorators: [{ type: ArrowFunctionDecorator },] } + ]; + return ArrowFunction; + }()); + + var NotArrayLiteralDecorator = {}; + var NotArrayLiteral = (function() { + function NotArrayLiteral(arg1) { + } + NotArrayLiteral.ctorParameters = function() { return 'StringsAreNotArrayLiterals'; }; + return NotArrayLiteral; + }()); + + var NotObjectLiteralDecorator = {}; + var NotObjectLiteral = (function() { + function NotObjectLiteral(arg1, arg2) { + } + NotObjectLiteral.ctorParameters = function() { return [ + "This is not an object literal", + { type: 'ParamType', decorators: [{ type: NotObjectLiteralDecorator },] }, + ]; }; + return NotObjectLiteral; + }()); + + var NoTypePropertyDecorator1 = {}; + var NoTypePropertyDecorator2 = {}; + var NoTypeProperty = (function() { + function NoTypeProperty(arg1, arg2) { + } + NoTypeProperty.ctorParameters = function() { return [ + { + type: 'ParamType', + decorators: [ + { notType: NoTypePropertyDecorator1 }, + { type: NoTypePropertyDecorator2 }, + ] + }, + ]; }; + return NoTypeProperty; + }()); + + var NotIdentifierDecorator = {}; + var NotIdentifier = (function() { + function NotIdentifier(arg1, arg2) { + } + NotIdentifier.ctorParameters = function() { return [ + { + type: 'ParamType', + decorators: [ + { type: 'StringsLiteralsAreNotIdentifiers' }, + { type: NotIdentifierDecorator }, + ] + }, + ]; }; + return NotIdentifier; + }()); + `, +}; + +const INVALID_CTOR_DECORATOR_ARGS_FILE = { + name: '/invalid_ctor_decorator_args.js', + contents: ` + var NoArgsPropertyDecorator = {}; + var NoArgsProperty = (function() { + function NoArgsProperty(arg1) { + } + NoArgsProperty.ctorParameters = function() { return [ + { type: 'ParamType', decorators: [{ type: NoArgsPropertyDecorator },] }, + ]; }; + return NoArgsProperty; + }()); + + var NoPropertyAssignmentDecorator = {}; + var args = [{ selector: '[ignored]' },]; + var NoPropertyAssignment = (function() { + function NoPropertyAssignment(arg1) { + } + NoPropertyAssignment.ctorParameters = function() { return [ + { type: 'ParamType', decorators: [{ type: NoPropertyAssignmentDecorator, args },] }, + ]; }; + return NoPropertyAssignment; + }()); + + var NotArrayLiteralDecorator = {}; + var NotArrayLiteral = (function() { + function NotArrayLiteral(arg1) { + } + NotArrayLiteral.ctorParameters = function() { return [ + { type: 'ParamType', decorators: [{ type: NotArrayLiteralDecorator, args: () => [{ selector: '[ignored]' },] },] }, + ]; }; + return NotArrayLiteral; + }()); + `, +}; + +const IMPORTS_FILES = [ + { + name: '/a.js', + contents: ` + export const a = 'a'; + `, + }, + { + name: '/b.js', + contents: ` + import {a} from './a.js'; + import {a as foo} from './a.js'; + + var b = a; + var c = foo; + var d = b; + `, + }, +]; + +const EXPORTS_FILES = [ + { + name: '/a.js', + contents: ` + export const a = 'a'; + `, + }, + { + name: '/b.js', + contents: ` + import {Directive} from '@angular/core'; + import {a} from './a'; + import {a as foo} from './a'; + export {Directive} from '@angular/core'; + export {a} from './a'; + export var b = a; + export var c = foo; + export var d = b; + export var e = 'e'; + export var DirectiveX = Directive; + export var SomeClass = (function() { + function SomeClass() {} + return SomeClass; + }()); + `, + }, +]; + +describe('Esm5ReflectionHost', () => { + + describe('getDecoratorsOfDeclaration()', () => { + it('should find the decorators on a class', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isVariableDeclaration); + debugger; + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators).toBeDefined(); + expect(decorators.length).toEqual(1); + + const decorator = decorators[0]; + expect(decorator.name).toEqual('Directive'); + expect(decorator.import).toEqual({name: 'Directive', from: '@angular/core'}); + expect(decorator.args !.map(arg => arg.getText())).toEqual([ + '{ selector: \'[someDirective]\' }', + ]); + }); + + it('should return null if the symbol is not a class', () => { + const program = makeProgram(FOO_FUNCTION_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const functionNode = + getDeclaration(program, FOO_FUNCTION_FILE.name, 'foo', ts.isFunctionDeclaration); + const decorators = host.getDecoratorsOfDeclaration(functionNode); + expect(decorators).toBe(null); + }); + + it('should return null if there are no decorators', () => { + const program = makeProgram(SIMPLE_CLASS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SIMPLE_CLASS_FILE.name, 'EmptyClass', ts.isVariableDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode); + expect(decorators).toBe(null); + }); + + it('should ignore `decorators` if it is not an array literal', () => { + const program = makeProgram(INVALID_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATORS_FILE.name, 'NotArrayLiteral', ts.isVariableDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode); + expect(decorators).toEqual([]); + }); + + it('should ignore decorator elements that are not object literals', () => { + const program = makeProgram(INVALID_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATORS_FILE.name, 'NotObjectLiteral', ts.isVariableDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NotObjectLiteralDecorator'})); + }); + + it('should ignore decorator elements that have no `type` property', () => { + const program = makeProgram(INVALID_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATORS_FILE.name, 'NoTypeProperty', ts.isVariableDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NoTypePropertyDecorator2'})); + }); + + it('should ignore decorator elements whose `type` value is not an identifier', () => { + const program = makeProgram(INVALID_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATORS_FILE.name, 'NotIdentifier', ts.isVariableDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NotIdentifierDecorator'})); + }); + + it('should use `getImportOfIdentifier()` to retrieve import info', () => { + const mockImportInfo = {} as Import; + const spy = spyOn(Esm5ReflectionHost.prototype, 'getImportOfIdentifier') + .and.returnValue(mockImportInfo); + + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isVariableDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toEqual(1); + expect(decorators[0].import).toBe(mockImportInfo); + + const typeIdentifier = spy.calls.mostRecent().args[0] as ts.Identifier; + expect(typeIdentifier.text).toBe('Directive'); + }); + + describe('(returned decorators `args`)', () => { + it('should be an empty array if decorator has no `args` property', () => { + const program = makeProgram(INVALID_DECORATOR_ARGS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATOR_ARGS_FILE.name, 'NoArgsProperty', ts.isVariableDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NoArgsPropertyDecorator'); + expect(decorators[0].args).toEqual([]); + }); + + it('should be an empty array if decorator\'s `args` has no property assignment', () => { + const program = makeProgram(INVALID_DECORATOR_ARGS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATOR_ARGS_FILE.name, 'NoPropertyAssignment', + ts.isVariableDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NoPropertyAssignmentDecorator'); + expect(decorators[0].args).toEqual([]); + }); + + it('should be an empty array if `args` property value is not an array literal', () => { + const program = makeProgram(INVALID_DECORATOR_ARGS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_DECORATOR_ARGS_FILE.name, 'NotArrayLiteral', ts.isVariableDeclaration); + const decorators = host.getDecoratorsOfDeclaration(classNode) !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NotArrayLiteralDecorator'); + expect(decorators[0].args).toEqual([]); + }); + }); + }); + + describe('getMembersOfClass()', () => { + it('should find decorated members on a class', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isVariableDeclaration); + const members = host.getMembersOfClass(classNode); + + const input1 = members.find(member => member.name === 'input1') !; + expect(input1.kind).toEqual(ClassMemberKind.Property); + expect(input1.isStatic).toEqual(false); + expect(input1.decorators !.map(d => d.name)).toEqual(['Input']); + + const input2 = members.find(member => member.name === 'input2') !; + expect(input2.kind).toEqual(ClassMemberKind.Property); + expect(input2.isStatic).toEqual(false); + expect(input1.decorators !.map(d => d.name)).toEqual(['Input']); + }); + + it('should find non decorated properties on a class', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isVariableDeclaration); + const members = host.getMembersOfClass(classNode); + + const instanceProperty = members.find(member => member.name === 'instanceProperty') !; + expect(instanceProperty.kind).toEqual(ClassMemberKind.Property); + expect(instanceProperty.isStatic).toEqual(false); + expect(ts.isBinaryExpression(instanceProperty.implementation !)).toEqual(true); + expect(instanceProperty.value !.getText()).toEqual(`'instance'`); + }); + + it('should find static methods on a class', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isVariableDeclaration); + debugger; + const members = host.getMembersOfClass(classNode); + + const staticMethod = members.find(member => member.name === 'staticMethod') !; + expect(staticMethod.kind).toEqual(ClassMemberKind.Method); + expect(staticMethod.isStatic).toEqual(true); + expect(ts.isFunctionExpression(staticMethod.implementation !)).toEqual(true); + }); + + it('should find static properties on a class', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isVariableDeclaration); + const members = host.getMembersOfClass(classNode); + + const staticProperty = members.find(member => member.name === 'staticProperty') !; + expect(staticProperty.kind).toEqual(ClassMemberKind.Property); + expect(staticProperty.isStatic).toEqual(true); + expect(ts.isPropertyAccessExpression(staticProperty.implementation !)).toEqual(true); + expect(staticProperty.value !.getText()).toEqual(`'static'`); + }); + + it('should throw if the symbol is not a class', () => { + const program = makeProgram(FOO_FUNCTION_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const functionNode = + getDeclaration(program, FOO_FUNCTION_FILE.name, 'foo', ts.isFunctionDeclaration); + expect(() => { + host.getMembersOfClass(functionNode); + }).toThrowError(`Attempted to get members of a non-class: "function foo() {}"`); + }); + + it('should return an empty array if there are no prop decorators', () => { + const program = makeProgram(SIMPLE_CLASS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = + getDeclaration(program, SIMPLE_CLASS_FILE.name, 'EmptyClass', ts.isVariableDeclaration); + const members = host.getMembersOfClass(classNode); + + expect(members).toEqual([]); + }); + + it('should not process decorated properties in `propDecorators` if it is not an object literal', + () => { + const program = makeProgram(INVALID_PROP_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATORS_FILE.name, 'NotObjectLiteral', + ts.isVariableDeclaration); + const members = host.getMembersOfClass(classNode); + + expect(members.map(member => member.name)).not.toContain('prop'); + }); + + it('should ignore prop decorator elements that are not object literals', () => { + const program = makeProgram(INVALID_PROP_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATORS_FILE.name, 'NotObjectLiteralProp', + ts.isVariableDeclaration); + const members = host.getMembersOfClass(classNode); + const prop = members.find(m => m.name === 'prop') !; + const decorators = prop.decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({ + name: 'NotObjectLiteralPropDecorator' + })); + }); + + it('should ignore prop decorator elements that have no `type` property', () => { + const program = makeProgram(INVALID_PROP_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATORS_FILE.name, 'NoTypeProperty', ts.isVariableDeclaration); + const members = host.getMembersOfClass(classNode); + const prop = members.find(m => m.name === 'prop') !; + const decorators = prop.decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NoTypePropertyDecorator2'})); + }); + + it('should ignore prop decorator elements whose `type` value is not an identifier', () => { + const program = makeProgram(INVALID_PROP_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATORS_FILE.name, 'NotIdentifier', ts.isVariableDeclaration); + const members = host.getMembersOfClass(classNode); + const prop = members.find(m => m.name === 'prop') !; + const decorators = prop.decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NotIdentifierDecorator'})); + }); + + it('should use `getImportOfIdentifier()` to retrieve import info', () => { + let callCount = 0; + const spy = spyOn(Esm5ReflectionHost.prototype, 'getImportOfIdentifier').and.callFake(() => { + callCount++; + return {name: `name${callCount}`, from: `from${callCount}`}; + }); + + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isVariableDeclaration); + const members = host.getMembersOfClass(classNode); + + expect(spy).toHaveBeenCalled(); + spy.calls.allArgs().forEach(arg => expect(arg[0].getText()).toEqual('Input')); + + const index = members.findIndex(member => member.name === 'input1'); + expect(members[index].decorators !.length).toBe(1); + expect(members[index].decorators ![0].import).toEqual({name: 'name1', from: 'from1'}); + }); + + describe('(returned prop decorators `args`)', () => { + it('should be an empty array if prop decorator has no `args` property', () => { + const program = makeProgram(INVALID_PROP_DECORATOR_ARGS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATOR_ARGS_FILE.name, 'NoArgsProperty', + ts.isVariableDeclaration); + const members = host.getMembersOfClass(classNode); + const prop = members.find(m => m.name === 'prop') !; + const decorators = prop.decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NoArgsPropertyDecorator'); + expect(decorators[0].args).toEqual([]); + }); + + it('should be an empty array if prop decorator\'s `args` has no property assignment', () => { + const program = makeProgram(INVALID_PROP_DECORATOR_ARGS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATOR_ARGS_FILE.name, 'NoPropertyAssignment', + ts.isVariableDeclaration); + const members = host.getMembersOfClass(classNode); + const prop = members.find(m => m.name === 'prop') !; + const decorators = prop.decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NoPropertyAssignmentDecorator'); + expect(decorators[0].args).toEqual([]); + }); + + it('should be an empty array if `args` property value is not an array literal', () => { + const program = makeProgram(INVALID_PROP_DECORATOR_ARGS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_PROP_DECORATOR_ARGS_FILE.name, 'NotArrayLiteral', + ts.isVariableDeclaration); + const members = host.getMembersOfClass(classNode); + const prop = members.find(m => m.name === 'prop') !; + const decorators = prop.decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NotArrayLiteralDecorator'); + expect(decorators[0].args).toEqual([]); + }); + }); + }); + + describe('getConstructorParameters', () => { + it('should find the decorated constructor parameters', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isVariableDeclaration); + const parameters = host.getConstructorParameters(classNode); + + expect(parameters).toBeDefined(); + expect(parameters !.map(parameter => parameter.name)).toEqual([ + '_viewContainer', '_template', 'injected' + ]); + expect(parameters !.map(parameter => parameter.type !.getText())).toEqual([ + 'ViewContainerRef', 'TemplateRef', 'undefined' + ]); + }); + + it('should throw if the symbol is not a class', () => { + const program = makeProgram(FOO_FUNCTION_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const functionNode = + getDeclaration(program, FOO_FUNCTION_FILE.name, 'foo', ts.isFunctionDeclaration); + expect(() => { host.getConstructorParameters(functionNode); }) + .toThrowError( + 'Attempted to get constructor parameters of a non-class: "function foo() {}"'); + }); + + // In ES5 there is no such thing as a constructor-less class + // it('should return `null` if there is no constructor', () => { }); + + it('should return an array even if there are no decorators', () => { + const program = makeProgram(SIMPLE_CLASS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SIMPLE_CLASS_FILE.name, 'NoDecoratorConstructorClass', ts.isVariableDeclaration); + const parameters = host.getConstructorParameters(classNode); + + expect(parameters).toEqual(jasmine.any(Array)); + expect(parameters !.length).toEqual(1); + expect(parameters ![0].name).toEqual('foo'); + expect(parameters ![0].decorators).toBe(null); + }); + + it('should return an empty array if there are no constructor parameters', () => { + const program = makeProgram(INVALID_CTOR_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATORS_FILE.name, 'NoParameters', ts.isVariableDeclaration); + const parameters = host.getConstructorParameters(classNode); + + expect(parameters).toEqual([]); + }); + + // In ES5 there are no arrow functions + // it('should ignore `ctorParameters` if it is an arrow function', () => { }); + + it('should ignore `ctorParameters` if it does not return an array literal', () => { + const program = makeProgram(INVALID_CTOR_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATORS_FILE.name, 'NotArrayLiteral', ts.isVariableDeclaration); + const parameters = host.getConstructorParameters(classNode); + + expect(parameters !.length).toBe(1); + expect(parameters ![0]).toEqual(jasmine.objectContaining({ + name: 'arg1', + decorators: null, + })); + }); + + describe('(returned parameters `decorators`)', () => { + it('should ignore param decorator elements that are not object literals', () => { + const program = makeProgram(INVALID_CTOR_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATORS_FILE.name, 'NotObjectLiteral', + ts.isVariableDeclaration); + const parameters = host.getConstructorParameters(classNode); + + expect(parameters !.length).toBe(2); + expect(parameters ![0]).toEqual(jasmine.objectContaining({ + name: 'arg1', + decorators: null, + })); + expect(parameters ![1]).toEqual(jasmine.objectContaining({ + name: 'arg2', + decorators: jasmine.any(Array) as any + })); + }); + + it('should ignore param decorator elements that have no `type` property', () => { + const program = makeProgram(INVALID_CTOR_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATORS_FILE.name, 'NoTypeProperty', ts.isVariableDeclaration); + const parameters = host.getConstructorParameters(classNode); + const decorators = parameters ![0].decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NoTypePropertyDecorator2'})); + }); + + it('should ignore param decorator elements whose `type` value is not an identifier', () => { + const program = makeProgram(INVALID_CTOR_DECORATORS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATORS_FILE.name, 'NotIdentifier', ts.isVariableDeclaration); + const parameters = host.getConstructorParameters(classNode); + const decorators = parameters ![0].decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0]).toEqual(jasmine.objectContaining({name: 'NotIdentifierDecorator'})); + }); + + it('should use `getImportOfIdentifier()` to retrieve import info', () => { + const mockImportInfo = {} as Import; + const spy = spyOn(Esm5ReflectionHost.prototype, 'getImportOfIdentifier') + .and.returnValue(mockImportInfo); + + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isVariableDeclaration); + const parameters = host.getConstructorParameters(classNode); + const decorators = parameters ![2].decorators !; + + expect(decorators.length).toEqual(1); + expect(decorators[0].import).toBe(mockImportInfo); + + const typeIdentifier = spy.calls.mostRecent().args[0] as ts.Identifier; + expect(typeIdentifier.text).toBe('Inject'); + }); + }); + + describe('(returned parameters `decorators.args`)', () => { + it('should be an empty array if param decorator has no `args` property', () => { + const program = makeProgram(INVALID_CTOR_DECORATOR_ARGS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATOR_ARGS_FILE.name, 'NoArgsProperty', + ts.isVariableDeclaration); + const parameters = host.getConstructorParameters(classNode); + expect(parameters !.length).toBe(1); + const decorators = parameters ![0].decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NoArgsPropertyDecorator'); + expect(decorators[0].args).toEqual([]); + }); + + it('should be an empty array if param decorator\'s `args` has no property assignment', () => { + const program = makeProgram(INVALID_CTOR_DECORATOR_ARGS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATOR_ARGS_FILE.name, 'NoPropertyAssignment', + ts.isVariableDeclaration); + const parameters = host.getConstructorParameters(classNode); + const decorators = parameters ![0].decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NoPropertyAssignmentDecorator'); + expect(decorators[0].args).toEqual([]); + }); + + it('should be an empty array if `args` property value is not an array literal', () => { + const program = makeProgram(INVALID_CTOR_DECORATOR_ARGS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, INVALID_CTOR_DECORATOR_ARGS_FILE.name, 'NotArrayLiteral', + ts.isVariableDeclaration); + const parameters = host.getConstructorParameters(classNode); + const decorators = parameters ![0].decorators !; + + expect(decorators.length).toBe(1); + expect(decorators[0].name).toBe('NotArrayLiteralDecorator'); + expect(decorators[0].args).toEqual([]); + }); + }); + }); + + describe('getImportOfIdentifier', () => { + it('should find the import of an identifier', () => { + const program = makeProgram(...IMPORTS_FILES); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const variableNode = + getDeclaration(program, IMPORTS_FILES[1].name, 'b', ts.isVariableDeclaration); + const importOfIdent = host.getImportOfIdentifier(variableNode.initializer as ts.Identifier); + + expect(importOfIdent).toEqual({name: 'a', from: './a.js'}); + }); + + it('should find the name by which the identifier was exported, not imported', () => { + const program = makeProgram(...IMPORTS_FILES); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const variableNode = + getDeclaration(program, IMPORTS_FILES[1].name, 'c', ts.isVariableDeclaration); + const importOfIdent = host.getImportOfIdentifier(variableNode.initializer as ts.Identifier); + + expect(importOfIdent).toEqual({name: 'a', from: './a.js'}); + }); + + it('should return null if the identifier was not imported', () => { + const program = makeProgram(...IMPORTS_FILES); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const variableNode = + getDeclaration(program, IMPORTS_FILES[1].name, 'd', ts.isVariableDeclaration); + const importOfIdent = host.getImportOfIdentifier(variableNode.initializer as ts.Identifier); + + expect(importOfIdent).toBeNull(); + }); + }); + + describe('getDeclarationOfIdentifier', () => { + it('should return the declaration of a locally defined identifier', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isVariableDeclaration); + const ctrDecorators = host.getConstructorParameters(classNode) !; + const identifierOfViewContainerRef = ctrDecorators[0].type !as ts.Identifier; + + const expectedDeclarationNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'ViewContainerRef', ts.isVariableDeclaration); + const actualDeclaration = host.getDeclarationOfIdentifier(identifierOfViewContainerRef); + expect(actualDeclaration).not.toBe(null); + expect(actualDeclaration !.node).toBe(expectedDeclarationNode); + expect(actualDeclaration !.viaModule).toBe(null); + }); + + it('should return the declaration of an externally defined identifier', () => { + const program = makeProgram(SOME_DIRECTIVE_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const classNode = getDeclaration( + program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', ts.isVariableDeclaration); + const classDecorators = host.getDecoratorsOfDeclaration(classNode) !; + const identifierOfDirective = ((classDecorators[0].node as ts.ObjectLiteralExpression) + .properties[0] as ts.PropertyAssignment) + .initializer as ts.Identifier; + + const expectedDeclarationNode = getDeclaration( + program, 'node_modules/@angular/core/index.ts', 'Directive', ts.isVariableDeclaration); + const actualDeclaration = host.getDeclarationOfIdentifier(identifierOfDirective); + expect(actualDeclaration).not.toBe(null); + expect(actualDeclaration !.node).toBe(expectedDeclarationNode); + expect(actualDeclaration !.viaModule).toBe('@angular/core'); + }); + }); + + describe('getExportsOfModule()', () => { + it('should return a map of all the exports from a given module', () => { + const program = makeProgram(...EXPORTS_FILES); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const file = program.getSourceFile(EXPORTS_FILES[1].name) !; + const exportDeclarations = host.getExportsOfModule(file); + expect(exportDeclarations).not.toBe(null); + expect(Array.from(exportDeclarations !.keys())).toEqual([ + 'Directive', + 'a', + 'b', + 'c', + 'd', + 'e', + 'DirectiveX', + 'SomeClass', + ]); + + const values = Array.from(exportDeclarations !.values()) + .map(declaration => [declaration.node.getText(), declaration.viaModule]); + expect(values).toEqual([ + // TODO: clarify what is expected here... + //[`Directive = callableClassDecorator()`, '@angular/core'], + [`Directive = callableClassDecorator()`, null], + [`a = 'a'`, null], + [`b = a`, null], + [`c = foo`, null], + [`d = b`, null], + [`e = 'e'`, null], + [`DirectiveX = Directive`, null], + [ + `SomeClass = (function() { + function SomeClass() {} + return SomeClass; + }())`, + null + ], + ]); + }); + }); + + describe('isClass()', () => { + it('should return true if a given node is an ES5 class declaration', () => { + const program = makeProgram(SIMPLE_CLASS_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const node = + getDeclaration(program, SIMPLE_CLASS_FILE.name, 'EmptyClass', ts.isVariableDeclaration); + expect(host.isClass(node)).toBe(true); + }); + + it('should return false if a given node is not an ES5 class declaration', () => { + const program = makeProgram(FOO_FUNCTION_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const node = getDeclaration(program, FOO_FUNCTION_FILE.name, 'foo', ts.isFunctionDeclaration); + expect(host.isClass(node)).toBe(false); + }); + }); +}); From 5f98984569eaeaf198013188f7547b07b303c11f Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 08:53:16 +0100 Subject: [PATCH 11/15] feat(ivy): implement esm2015 and esm5 file parsers --- .../src/ngcc/src/parsing/esm2015_parser.ts | 54 ++++++++ .../src/ngcc/src/parsing/esm5_parser.ts | 59 +++++++++ .../src/ngcc/src/parsing/file_parser.ts | 35 +++++ .../src/ngcc/src/parsing/parsed_class.ts | 26 ++++ .../src/ngcc/src/parsing/parsed_file.ts | 23 ++++ .../src/ngcc/src/parsing/utils.ts | 52 ++++++++ .../ngcc/test/parser/esm2015_parser_spec.ts | 56 ++++++++ .../src/ngcc/test/parser/esm5_parser_spec.ts | 64 +++++++++ .../src/ngcc/test/parser/parser_spec.ts | 124 ++++++++++++++++++ 9 files changed, 493 insertions(+) create mode 100644 packages/compiler-cli/src/ngcc/src/parsing/esm2015_parser.ts create mode 100644 packages/compiler-cli/src/ngcc/src/parsing/esm5_parser.ts create mode 100644 packages/compiler-cli/src/ngcc/src/parsing/file_parser.ts create mode 100644 packages/compiler-cli/src/ngcc/src/parsing/parsed_class.ts create mode 100644 packages/compiler-cli/src/ngcc/src/parsing/parsed_file.ts create mode 100644 packages/compiler-cli/src/ngcc/src/parsing/utils.ts create mode 100644 packages/compiler-cli/src/ngcc/test/parser/esm2015_parser_spec.ts create mode 100644 packages/compiler-cli/src/ngcc/test/parser/esm5_parser_spec.ts create mode 100644 packages/compiler-cli/src/ngcc/test/parser/parser_spec.ts diff --git a/packages/compiler-cli/src/ngcc/src/parsing/esm2015_parser.ts b/packages/compiler-cli/src/ngcc/src/parsing/esm2015_parser.ts new file mode 100644 index 000000000000..a476365fc8a0 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/parsing/esm2015_parser.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; + +import {NgccReflectionHost} from '../host/ngcc_host'; +import {getOriginalSymbol, isDefined} from '../utils'; + +import {FileParser} from './file_parser'; +import {ParsedClass} from './parsed_class'; +import {ParsedFile} from './parsed_file'; + +export class Esm2015FileParser implements FileParser { + checker = this.program.getTypeChecker(); + + constructor(protected program: ts.Program, protected host: NgccReflectionHost) {} + + parseFile(file: ts.SourceFile): ParsedFile[] { + const moduleSymbol = this.checker.getSymbolAtLocation(file); + const map = new Map(); + if (moduleSymbol) { + const exportClasses = this.checker.getExportsOfModule(moduleSymbol) + .map(getOriginalSymbol(this.checker)) + .filter(exportSymbol => exportSymbol.flags & ts.SymbolFlags.Class); + + const classDeclarations = exportClasses.map(exportSymbol => exportSymbol.valueDeclaration) + .filter(isDefined) + .filter(ts.isClassDeclaration); + + const decoratedClasses = + classDeclarations + .map(declaration => { + const decorators = this.host.getDecoratorsOfDeclaration(declaration); + return decorators && declaration.name && + new ParsedClass(declaration.name.text, declaration, decorators); + }) + .filter(isDefined); + + decoratedClasses.forEach(clazz => { + const file = clazz.declaration.getSourceFile(); + if (!map.has(file)) { + map.set(file, new ParsedFile(file)); + } + map.get(file) !.decoratedClasses.push(clazz); + }); + } + return Array.from(map.values()); + } +} diff --git a/packages/compiler-cli/src/ngcc/src/parsing/esm5_parser.ts b/packages/compiler-cli/src/ngcc/src/parsing/esm5_parser.ts new file mode 100644 index 000000000000..e4c91de62851 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/parsing/esm5_parser.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; + +import {NgccReflectionHost} from '../host/ngcc_host'; +import {getNameText, getOriginalSymbol, isDefined} from '../utils'; + +import {FileParser} from './file_parser'; +import {ParsedClass} from './parsed_class'; +import {ParsedFile} from './parsed_file'; + + + +/** + * Parses ESM5 package files for decoratrs classes. + * ESM5 "classes" are actually functions wrapped by and returned + * from an IFEE. + */ +export class Esm5FileParser implements FileParser { + checker = this.program.getTypeChecker(); + + constructor(protected program: ts.Program, protected host: NgccReflectionHost) {} + + parseFile(file: ts.SourceFile): ParsedFile[] { + const moduleSymbol = this.checker.getSymbolAtLocation(file); + const map = new Map(); + const getParsedClass = (declaration: ts.VariableDeclaration) => { + const decorators = this.host.getDecoratorsOfDeclaration(declaration); + if (decorators) { + return new ParsedClass(getNameText(declaration.name), declaration, decorators); + } + }; + + if (moduleSymbol) { + const classDeclarations = this.checker.getExportsOfModule(moduleSymbol) + .map(getOriginalSymbol(this.checker)) + .map(exportSymbol => exportSymbol.valueDeclaration) + .filter(isDefined) + .filter(ts.isVariableDeclaration); + + const decoratedClasses = classDeclarations.map(getParsedClass).filter(isDefined); + + decoratedClasses.forEach(clazz => { + const file = clazz.declaration.getSourceFile(); + if (!map.has(file)) { + map.set(file, new ParsedFile(file)); + } + map.get(file) !.decoratedClasses.push(clazz); + }); + } + return Array.from(map.values()); + } +} diff --git a/packages/compiler-cli/src/ngcc/src/parsing/file_parser.ts b/packages/compiler-cli/src/ngcc/src/parsing/file_parser.ts new file mode 100644 index 000000000000..4b73263629d1 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/parsing/file_parser.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; +import {ParsedFile} from './parsed_file'; + +/** + * Classes that implement this interface can parse a file in a package to + * find the "declarations" (representing exported classes), that are decorated with core + * decorators, such as `@Component`, `@Injectable`, etc. + * + * Identifying classes can be different depending upon the format of the source file. + * + * For example: + * + * - ES2015 files contain `class Xxxx {...}` style declarations + * - ES5 files contain `var Xxxx = (function () { function Xxxx() { ... }; return Xxxx; })();` style + * declarations + * - UMD have similar declarations to ES5 files but the whole thing is wrapped in IIFE module + * wrapper + * function. + */ +export interface FileParser { + /** + * Parse a file to identify the decorated classes. + * + * @param file The the entry point file for identifying classes to process. + * @returns A `ParsedFiles` collection that holds the decorated classes and import information. + */ + parseFile(file: ts.SourceFile): ParsedFile[]; +} diff --git a/packages/compiler-cli/src/ngcc/src/parsing/parsed_class.ts b/packages/compiler-cli/src/ngcc/src/parsing/parsed_class.ts new file mode 100644 index 000000000000..c6cad7457dff --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/parsing/parsed_class.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; +import {Decorator} from '../../../ngtsc/host'; + +/** + * A simple container that holds the details of a decorated class that has been + * parsed out of a package. + */ +export class ParsedClass { + /** + * Initialize a `DecoratedClass` that was found by parsing a package. + * @param name The name of the class that has been found. This is mostly used + * for informational purposes. + * @param declaration The TypeScript AST node where this class is declared + * @param decorators The collection of decorators that have been found on this class. + */ + constructor( + public name: string, public declaration: ts.Declaration, public decorators: Decorator[], ) {} +} diff --git a/packages/compiler-cli/src/ngcc/src/parsing/parsed_file.ts b/packages/compiler-cli/src/ngcc/src/parsing/parsed_file.ts new file mode 100644 index 000000000000..117c46008204 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/parsing/parsed_file.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; +import {ParsedClass} from './parsed_class'; + +/** + * Information about a source file that has been parsed to + * extract all the decorated exported classes. + */ +export class ParsedFile { + /** + * The decorated exported classes that have been parsed out + * from the file. + */ + public decoratedClasses: ParsedClass[] = []; + constructor(public sourceFile: ts.SourceFile) {} +} diff --git a/packages/compiler-cli/src/ngcc/src/parsing/utils.ts b/packages/compiler-cli/src/ngcc/src/parsing/utils.ts new file mode 100644 index 000000000000..e9270e0d8533 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/parsing/utils.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {readFileSync} from 'fs'; +import {dirname, resolve} from 'path'; +import {find} from 'shelljs'; + +/** + * Match paths to package.json files. + */ +const PACKAGE_JSON_REGEX = /\/package\.json$/; + +/** + * Match paths that have a `node_modules` segment at the start or in the middle. + */ +const NODE_MODULES_REGEX = /(?:^|\/)node_modules\//; + +/** + * Search the `rootDirectory` and its subdirectories to find package.json files. + * It ignores node dependencies, i.e. those under `node_modules` folders. + * @param rootDirectory the directory in which we should search. + */ +export function findAllPackageJsonFiles(rootDirectory: string): string[] { + // TODO(gkalpak): Investigate whether skipping `node_modules/` directories (instead of traversing + // them and filtering out the results later) makes a noticeable difference. + const paths = Array.from(find(rootDirectory)); + return paths.filter( + path => PACKAGE_JSON_REGEX.test(path) && + !NODE_MODULES_REGEX.test(path.slice(rootDirectory.length))); +} + +/** + * Identify the entry points of a package. + * @param packageDirectory The absolute path to the root directory that contains this package. + * @param format The format of the entry point within the package. + * @returns A collection of paths that point to entry points for this package. + */ +export function getEntryPoints(packageDirectory: string, format: string): string[] { + const packageJsonPaths = findAllPackageJsonFiles(packageDirectory); + return packageJsonPaths + .map(packageJsonPath => { + const entryPointPackageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const relativeEntryPointPath = entryPointPackageJson[format]; + return relativeEntryPointPath && resolve(dirname(packageJsonPath), relativeEntryPointPath); + }) + .filter(entryPointPath => entryPointPath); +} diff --git a/packages/compiler-cli/src/ngcc/test/parser/esm2015_parser_spec.ts b/packages/compiler-cli/src/ngcc/test/parser/esm2015_parser_spec.ts new file mode 100644 index 000000000000..4607d4a9cf0f --- /dev/null +++ b/packages/compiler-cli/src/ngcc/test/parser/esm2015_parser_spec.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; + +import {Esm2015ReflectionHost} from '../../src/host/esm2015_host'; +import {Esm2015FileParser} from '../../src/parsing/esm2015_parser'; +import {makeProgram} from '../helpers/utils'; + +const BASIC_FILE = { + name: '/primary.js', + contents: ` + class A {} + A.decorators = [ + { type: Directive, args: [{ selector: '[a]' }] } + ]; + + class B {} + B.decorators = [ + { type: Directive, args: [{ selector: '[b]' }] } + ]; + + function x() {} + + function y() {} + + class C {} + + export { A, x, C }; + ` +}; + +describe('Esm2015PackageParser', () => { + describe('getDecoratedClasses()', () => { + it('should return an array of object for each class that is exported and decorated', () => { + const program = makeProgram(BASIC_FILE); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const parser = new Esm2015FileParser(program, host); + + const parsedFiles = parser.parseFile(program.getSourceFile(BASIC_FILE.name) !); + + expect(parsedFiles.length).toEqual(1); + const decoratedClasses = parsedFiles[0].decoratedClasses; + expect(decoratedClasses.length).toEqual(1); + const decoratedClass = decoratedClasses[0]; + expect(decoratedClass.name).toEqual('A'); + expect(ts.isClassDeclaration(decoratedClass.declaration)).toBeTruthy(); + expect(decoratedClass.decorators.map(decorator => decorator.name)).toEqual(['Directive']); + }); + }); +}); \ No newline at end of file diff --git a/packages/compiler-cli/src/ngcc/test/parser/esm5_parser_spec.ts b/packages/compiler-cli/src/ngcc/test/parser/esm5_parser_spec.ts new file mode 100644 index 000000000000..f69ce36a78bb --- /dev/null +++ b/packages/compiler-cli/src/ngcc/test/parser/esm5_parser_spec.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; + +import {Esm5ReflectionHost} from '../../src/host/esm5_host'; +import {Esm5FileParser} from '../../src/parsing/esm5_parser'; +import {makeProgram} from '../helpers/utils'; + +const BASIC_FILE = { + name: '/primary.js', + contents: ` + var A = (function() { + function A() {} + A.decorators = [ + { type: Directive, args: [{ selector: '[a]' }] } + ]; + return A; + }()); + + var B = (function() { + function B() {} + B.decorators = [ + { type: Directive, args: [{ selector: '[b]' }] } + ]; + return B; + }()); + + function x() {} + + function y() {} + + var C = (function() { + function C() {} + return C; + }); + + export { A, x, C }; + ` +}; + +describe('Esm5FileParser', () => { + describe('getDecoratedClasses()', () => { + it('should return an array of object for each class that is exported and decorated', () => { + const program = makeProgram(BASIC_FILE); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const parser = new Esm5FileParser(program, host); + + const parsedFiles = parser.parseFile(program.getSourceFile(BASIC_FILE.name) !); + + expect(parsedFiles.length).toEqual(1); + const decoratedClasses = parsedFiles[0].decoratedClasses; + expect(decoratedClasses.length).toEqual(1); + const decoratedClass = decoratedClasses[0]; + expect(decoratedClass.name).toEqual('A'); + expect(decoratedClass.decorators.map(decorator => decorator.name)).toEqual(['Directive']); + }); + }); +}); \ No newline at end of file diff --git a/packages/compiler-cli/src/ngcc/test/parser/parser_spec.ts b/packages/compiler-cli/src/ngcc/test/parser/parser_spec.ts new file mode 100644 index 000000000000..559fcea3f594 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/test/parser/parser_spec.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as mockFs from 'mock-fs'; +import {findAllPackageJsonFiles, getEntryPoints} from '../../src/parsing/utils'; + +function createMockFileSystem() { + mockFs({ + '/node_modules/@angular/common': { + 'package.json': '{ "fesm2015": "./fesm2015/common.js", "fesm5": "./fesm5/common.js" }', + 'fesm2015': { + 'common.js': 'DUMMY CONTENT', + 'http.js': 'DUMMY CONTENT', + 'http/testing.js': 'DUMMY CONTENT', + 'testing.js': 'DUMMY CONTENT', + }, + 'http': { + 'package.json': '{ "fesm2015": "../fesm2015/http.js", "fesm5": "../fesm5/http.js" }', + 'testing': { + 'package.json': + '{ "fesm2015": "../../fesm2015/http/testing.js", "fesm5": "../../fesm5/http/testing.js" }', + }, + }, + 'testing': { + 'package.json': '{ "fesm2015": "../fesm2015/testing.js", "fesm5": "../fesm5/testing.js" }', + }, + 'node_modules': { + 'tslib': { + 'package.json': '{ }', + 'node_modules': { + 'other-lib': { + 'package.json': '{ }', + }, + }, + }, + }, + }, + '/node_modules/@angular/other': { + 'not-package.json': '{ "fesm2015": "./fesm2015/other.js" }', + 'package.jsonot': '{ "fesm5": "./fesm5/other.js" }', + }, + '/node_modules/@angular/other2': { + 'node_modules_not': { + 'lib1': { + 'package.json': '{ }', + }, + }, + 'not_node_modules': { + 'lib2': { + 'package.json': '{ }', + }, + }, + }, + }); +} + +function restoreRealFileSystem() { + mockFs.restore(); +} + +describe('findAllPackageJsonFiles()', () => { + beforeEach(createMockFileSystem); + afterEach(restoreRealFileSystem); + + it('should find the `package.json` files below the specified directory', () => { + const paths = findAllPackageJsonFiles('/node_modules/@angular/common'); + expect(paths.sort()).toEqual([ + '/node_modules/@angular/common/http/package.json', + '/node_modules/@angular/common/http/testing/package.json', + '/node_modules/@angular/common/package.json', + '/node_modules/@angular/common/testing/package.json', + ]); + }); + + it('should not find `package.json` files under `node_modules/`', () => { + const paths = findAllPackageJsonFiles('/node_modules/@angular/common'); + expect(paths).not.toContain('/node_modules/@angular/common/node_modules/tslib/package.json'); + expect(paths).not.toContain( + '/node_modules/@angular/common/node_modules/tslib/node_modules/other-lib/package.json'); + }); + + it('should exactly match the name of `package.json` files', () => { + const paths = findAllPackageJsonFiles('/node_modules/@angular/other'); + expect(paths).toEqual([]); + }); + + it('should exactly match the name of `node_modules/` directory', () => { + const paths = findAllPackageJsonFiles('/node_modules/@angular/other2'); + expect(paths).toEqual([ + '/node_modules/@angular/other2/node_modules_not/lib1/package.json', + '/node_modules/@angular/other2/not_node_modules/lib2/package.json', + ]); + }); +}); + +describe('getEntryPoints()', () => { + beforeEach(createMockFileSystem); + afterEach(restoreRealFileSystem); + + it('should return the paths for the specified format from each package.json', () => { + const paths = getEntryPoints('/node_modules/@angular/common', 'fesm2015'); + expect(paths.sort()).toEqual([ + '/node_modules/@angular/common/fesm2015/common.js', + '/node_modules/@angular/common/fesm2015/http.js', + '/node_modules/@angular/common/fesm2015/http/testing.js', + '/node_modules/@angular/common/fesm2015/testing.js', + ]); + }); + + it('should return an empty array if there are no matching package.json files', () => { + const paths = getEntryPoints('/node_modules/@angular/other', 'fesm2015'); + expect(paths).toEqual([]); + }); + + it('should return an empty array if there are no matching formats', () => { + const paths = getEntryPoints('/node_modules/@angular/other', 'main'); + expect(paths).toEqual([]); + }); +}); From 00c2f003cb4d2d02947aa1efb1f48d69fc6bb860 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 08:55:04 +0100 Subject: [PATCH 12/15] feat(ivy): implement ngcc `Analyzer` --- .../compiler-cli/src/ngcc/src/analyzer.ts | 97 ++++++++++++++++ .../compiler-cli/src/ngcc/test/BUILD.bazel | 1 + .../src/ngcc/test/analyzer_spec.ts | 109 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 packages/compiler-cli/src/ngcc/src/analyzer.ts create mode 100644 packages/compiler-cli/src/ngcc/test/analyzer_spec.ts diff --git a/packages/compiler-cli/src/ngcc/src/analyzer.ts b/packages/compiler-cli/src/ngcc/src/analyzer.ts new file mode 100644 index 000000000000..4dca46ae6723 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/analyzer.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as fs from 'fs'; +import * as ts from 'typescript'; +import {ComponentDecoratorHandler, DirectiveDecoratorHandler, InjectableDecoratorHandler, NgModuleDecoratorHandler, PipeDecoratorHandler, ResourceLoader, SelectorScopeRegistry} from '../../ngtsc/annotations'; +import {Decorator} from '../../ngtsc/host'; +import {CompileResult, DecoratorHandler} from '../../ngtsc/transform'; +import {NgccReflectionHost} from './host/ngcc_host'; +import {ParsedClass} from './parsing/parsed_class'; +import {ParsedFile} from './parsing/parsed_file'; +import {isDefined} from './utils'; + +export interface AnalyzedClass extends ParsedClass { + handler: DecoratorHandler; + analysis: any; + diagnostics?: ts.Diagnostic[]; + compilation: CompileResult[]; +} + +export interface AnalyzedFile { + analyzedClasses: AnalyzedClass[]; + sourceFile: ts.SourceFile; +} + +export interface MatchingHandler { + handler: DecoratorHandler; + decorator: Decorator; +} + +/** + * `ResourceLoader` which directly uses the filesystem to resolve resources synchronously. + */ +export class FileResourceLoader implements ResourceLoader { + load(url: string): string { return fs.readFileSync(url, 'utf8'); } +} + +export class Analyzer { + resourceLoader = new FileResourceLoader(); + scopeRegistry = new SelectorScopeRegistry(this.typeChecker, this.host); + handlers: DecoratorHandler[] = [ + new ComponentDecoratorHandler( + this.typeChecker, this.host, this.scopeRegistry, false, this.resourceLoader), + new DirectiveDecoratorHandler(this.typeChecker, this.host, this.scopeRegistry, false), + new InjectableDecoratorHandler(this.host, false), + new NgModuleDecoratorHandler(this.typeChecker, this.host, this.scopeRegistry, false), + new PipeDecoratorHandler(this.typeChecker, this.host, this.scopeRegistry, false), + ]; + + constructor(private typeChecker: ts.TypeChecker, private host: NgccReflectionHost) {} + + /** + * Analyize a parsed file to generate the information about decorated classes that + * should be converted to use ivy definitions. + * @param file The file to be analysed for decorated classes. + */ + analyzeFile(file: ParsedFile): AnalyzedFile { + const analyzedClasses = + file.decoratedClasses.map(clazz => this.analyzeClass(file.sourceFile, clazz)) + .filter(isDefined); + + return { + analyzedClasses, + sourceFile: file.sourceFile, + }; + } + + protected analyzeClass(file: ts.SourceFile, clazz: ParsedClass): AnalyzedClass|undefined { + const matchingHandlers = + this.handlers.map(handler => ({handler, decorator: handler.detect(clazz.decorators)})) + .filter(isMatchingHandler); + + if (matchingHandlers.length > 1) { + throw new Error('TODO.Diagnostic: Class has multiple Angular decorators.'); + } + + if (matchingHandlers.length == 0) { + return undefined; + } + + const {handler, decorator} = matchingHandlers[0]; + const {analysis, diagnostics} = handler.analyze(clazz.declaration, decorator); + let compilation = handler.compile(clazz.declaration, analysis); + if (!Array.isArray(compilation)) { + compilation = [compilation]; + } + return {...clazz, handler, analysis, diagnostics, compilation}; + } +} + +function isMatchingHandler(handler: Partial>): handler is MatchingHandler { + return !!handler.decorator; +} \ No newline at end of file diff --git a/packages/compiler-cli/src/ngcc/test/BUILD.bazel b/packages/compiler-cli/src/ngcc/test/BUILD.bazel index c54936a989bb..5f534a566260 100644 --- a/packages/compiler-cli/src/ngcc/test/BUILD.bazel +++ b/packages/compiler-cli/src/ngcc/test/BUILD.bazel @@ -13,6 +13,7 @@ ts_library( "//packages/compiler-cli/src/ngcc", "//packages/compiler-cli/src/ngtsc/host", "//packages/compiler-cli/src/ngtsc/testing", + "//packages/compiler-cli/src/ngtsc/transform", ], ) diff --git a/packages/compiler-cli/src/ngcc/test/analyzer_spec.ts b/packages/compiler-cli/src/ngcc/test/analyzer_spec.ts new file mode 100644 index 000000000000..45bb22a44d90 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/test/analyzer_spec.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; +import {Decorator} from '../../ngtsc/host'; +import {DecoratorHandler} from '../../ngtsc/transform'; +import {AnalyzedFile, Analyzer} from '../src/analyzer'; +import {Esm2015ReflectionHost} from '../src/host/esm2015_host'; +import {ParsedClass} from '../src/parsing/parsed_class'; +import {ParsedFile} from '../src/parsing/parsed_file'; +import {getDeclaration, makeProgram} from './helpers/utils'; + +const TEST_PROGRAM = { + name: 'test.js', + contents: ` + import {Component, Injectable} from '@angular/core'; + + @Component() + export class MyComponent {} + + @Injectable() + export class MyService {} + ` +}; + +function createTestHandler() { + const handler = jasmine.createSpyObj>('TestDecoratorHandler', [ + 'detect', + 'analyze', + 'compile', + ]); + // Only detect the Component decorator + handler.detect.and.callFake( + (decorators: Decorator[]) => decorators.find(d => d.name === 'Component')); + // The "test" analysis is just the name of the decorator being analyzed + handler.analyze.and.callFake( + ((decl: ts.Declaration, dec: Decorator) => ({analysis: dec.name, diagnostics: null}))); + // The "test" compilation result is just the name of the decorator being compiled + handler.compile.and.callFake(((decl: ts.Declaration, analysis: any) => ({analysis}))); + return handler; +} + +function createParsedFile(program: ts.Program) { + const file = new ParsedFile(program.getSourceFile('test.js') !); + + const componentClass = getDeclaration(program, 'test.js', 'MyComponent', ts.isClassDeclaration); + file.decoratedClasses.push(new ParsedClass('MyComponent', {} as any, [{ + name: 'Component', + import: {from: '@angular/core', name: 'Component'}, + node: null as any, + args: null + }])); + + const serviceClass = getDeclaration(program, 'test.js', 'MyService', ts.isClassDeclaration); + file.decoratedClasses.push(new ParsedClass('MyService', {} as any, [{ + name: 'Injectable', + import: {from: '@angular/core', name: 'Injectable'}, + node: null as any, + args: null + }])); + + return file; +} + +describe('Analyzer', () => { + describe('analyzeFile()', () => { + let program: ts.Program; + let testHandler: jasmine.SpyObj>; + let result: AnalyzedFile; + + beforeEach(() => { + program = makeProgram(TEST_PROGRAM); + const file = createParsedFile(program); + const analyzer = new Analyzer( + program.getTypeChecker(), new Esm2015ReflectionHost(program.getTypeChecker())); + testHandler = createTestHandler(); + analyzer.handlers = [testHandler]; + result = analyzer.analyzeFile(file); + }); + + it('should return an object containing a reference to the original source file', + () => { expect(result.sourceFile).toBe(program.getSourceFile('test.js') !); }); + + it('should call detect on the decorator handlers with each class from the parsed file', () => { + expect(testHandler.detect).toHaveBeenCalledTimes(2); + expect(testHandler.detect.calls.allArgs()[0][0]).toEqual([jasmine.objectContaining( + {name: 'Component'})]); + expect(testHandler.detect.calls.allArgs()[1][0]).toEqual([jasmine.objectContaining( + {name: 'Injectable'})]); + }); + + it('should return an object containing the classes that were analyzed', () => { + expect(result.analyzedClasses.length).toEqual(1); + expect(result.analyzedClasses[0].name).toEqual('MyComponent'); + }); + + it('should analyze and compile the classes that are detected', () => { + expect(testHandler.analyze).toHaveBeenCalledTimes(1); + expect(testHandler.analyze.calls.allArgs()[0][1].name).toEqual('Component'); + + expect(testHandler.compile).toHaveBeenCalledTimes(1); + expect(testHandler.compile.calls.allArgs()[0][1]).toEqual('Component'); + }); + }); +}); From bdb271c109c38fe9bb5c6cf62f9a5ee5c285e91a Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 10:23:37 +0100 Subject: [PATCH 13/15] feat(ivy): implement esm2015 and esm5 ngcc file renderers --- .../ngcc/src/rendering/esm2015_renderer.ts | 62 +++++ .../src/ngcc/src/rendering/esm5_renderer.ts | 16 ++ .../src/ngcc/src/rendering/renderer.ts | 245 ++++++++++++++++++ .../test/rendering/esm2015_renderer_spec.ts | 188 ++++++++++++++ .../ngcc/test/rendering/esm5_renderer_spec.ts | 223 ++++++++++++++++ .../src/ngcc/test/rendering/renderer_spec.ts | 182 +++++++++++++ 6 files changed, 916 insertions(+) create mode 100644 packages/compiler-cli/src/ngcc/src/rendering/esm2015_renderer.ts create mode 100644 packages/compiler-cli/src/ngcc/src/rendering/esm5_renderer.ts create mode 100644 packages/compiler-cli/src/ngcc/src/rendering/renderer.ts create mode 100644 packages/compiler-cli/src/ngcc/test/rendering/esm2015_renderer_spec.ts create mode 100644 packages/compiler-cli/src/ngcc/test/rendering/esm5_renderer_spec.ts create mode 100644 packages/compiler-cli/src/ngcc/test/rendering/renderer_spec.ts diff --git a/packages/compiler-cli/src/ngcc/src/rendering/esm2015_renderer.ts b/packages/compiler-cli/src/ngcc/src/rendering/esm2015_renderer.ts new file mode 100644 index 000000000000..a841b0c61686 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/rendering/esm2015_renderer.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; +import MagicString from 'magic-string'; +import {NgccReflectionHost} from '../host/ngcc_host'; +import {AnalyzedClass} from '../analyzer'; +import {Renderer} from './renderer'; + +export class Esm2015Renderer extends Renderer { + constructor(protected host: NgccReflectionHost) { super(); } + + /** + * Add the imports at the top of the file + */ + addImports(output: MagicString, imports: {name: string; as: string;}[]): void { + // The imports get inserted at the very top of the file. + imports.forEach(i => { output.appendLeft(0, `import * as ${i.as} from '${i.name}';\n`); }); + } + + /** + * Add the definitions to each decorated class + */ + addDefinitions(output: MagicString, analyzedClass: AnalyzedClass, definitions: string): void { + const classSymbol = this.host.getClassSymbol(analyzedClass.declaration); + if (!classSymbol) { + throw new Error(`Analyzed class does not have a valid symbol: ${analyzedClass.name}`); + } + const insertionPoint = classSymbol.valueDeclaration !.getEnd(); + output.appendLeft(insertionPoint, '\n' + definitions); + } + + /** + * Remove static decorator properties from classes + */ + removeDecorators(output: MagicString, decoratorsToRemove: Map): void { + decoratorsToRemove.forEach((nodesToRemove, containerNode) => { + if (ts.isArrayLiteralExpression(containerNode)) { + const items = containerNode.elements; + if (items.length === nodesToRemove.length) { + // remove any trailing semi-colon + const end = (output.slice(containerNode.getEnd(), containerNode.getEnd() + 1) === ';') ? + containerNode.getEnd() + 1 : + containerNode.getEnd(); + output.remove(containerNode.parent !.getFullStart(), end); + } else { + nodesToRemove.forEach(node => { + // remove any trailing comma + const end = (output.slice(node.getEnd(), node.getEnd() + 1) === ',') ? + node.getEnd() + 1 : + node.getEnd(); + output.remove(node.getFullStart(), end); + }); + } + } + }); + } +} diff --git a/packages/compiler-cli/src/ngcc/src/rendering/esm5_renderer.ts b/packages/compiler-cli/src/ngcc/src/rendering/esm5_renderer.ts new file mode 100644 index 000000000000..5f5d7fdec3e9 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/rendering/esm5_renderer.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; +import MagicString from 'magic-string'; +import {NgccReflectionHost} from '../host/ngcc_host'; +import {AnalyzedClass, AnalyzedFile} from '../analyzer'; +import {Esm2015Renderer} from './esm2015_renderer'; + +export class Esm5Renderer extends Esm2015Renderer { + constructor(host: NgccReflectionHost) { super(host); } +} diff --git a/packages/compiler-cli/src/ngcc/src/rendering/renderer.ts b/packages/compiler-cli/src/ngcc/src/rendering/renderer.ts new file mode 100644 index 000000000000..8d33aa3f15df --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/rendering/renderer.ts @@ -0,0 +1,245 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {dirname} from 'path'; +import * as ts from 'typescript'; + +import MagicString from 'magic-string'; +import {commentRegex, mapFileCommentRegex, fromJSON, fromSource, fromMapFileSource, fromObject, generateMapFileComment, removeComments, removeMapFileComments, SourceMapConverter} from 'convert-source-map'; +import {SourceMapConsumer, SourceMapGenerator, RawSourceMap} from 'source-map'; +import {Expression, Statement, WrappedNodeExpr, WritePropExpr} from '@angular/compiler'; +import {AnalyzedClass, AnalyzedFile} from '../analyzer'; +import {Decorator} from '../../../ngtsc/host'; +import {ImportManager, translateStatement} from '../../../ngtsc/transform/src/translator'; + +interface SourceMapInfo { + source: string; + map: SourceMapConverter|null; + isInline: boolean; +} + +/** + * The results of rendering an analyzed file. + */ +export interface RenderResult { + /** + * The file that has been rendered. + */ + file: AnalyzedFile; + /** + * The rendered source file. + */ + source: FileInfo; + /** + * The rendered source map file. + */ + map: FileInfo|null; +} + +/** + * Information about a file that has been rendered. + */ +export interface FileInfo { + /** + * Path to where the file should be written. + */ + path: string; + /** + * The contents of the file to be be written. + */ + contents: string; +} + +/** + * A base-class for rendering an `AnalyzedClass`. + * Package formats have output files that must be rendered differently, + * Concrete sub-classes must implement the `addImports`, `addDefinitions` and + * `removeDecorators` abstract methods. + */ +export abstract class Renderer { + /** + * Render the source code and source-map for an Analyzed file. + * @param file The analyzed file to render. + * @param targetPath The absolute path where the rendered file will be written. + */ + renderFile(file: AnalyzedFile, targetPath: string): RenderResult { + const importManager = new ImportManager(false, 'ɵngcc'); + const input = this.extractSourceMap(file.sourceFile); + + const outputText = new MagicString(input.source); + const decoratorsToRemove = new Map(); + + file.analyzedClasses.forEach(clazz => { + const renderedDefinition = renderDefinitions(file.sourceFile, clazz, importManager); + this.addDefinitions(outputText, clazz, renderedDefinition); + this.trackDecorators(clazz.decorators, decoratorsToRemove); + }); + + this.addImports(outputText, importManager.getAllImports(file.sourceFile.fileName, null)); + // QUESTION: do we need to remove contructor param metadata and property decorators? + this.removeDecorators(outputText, decoratorsToRemove); + + return this.renderSourceAndMap(file, input, outputText, targetPath); + } + + protected abstract addImports(output: MagicString, imports: {name: string, as: string}[]): void; + protected abstract addDefinitions( + output: MagicString, analyzedClass: AnalyzedClass, definitions: string): void; + protected abstract removeDecorators( + output: MagicString, decoratorsToRemove: Map): void; + + /** + * Add the decorator nodes that are to be removed to a map + * So that we can tell if we should remove the entire decorator property + */ + protected trackDecorators(decorators: Decorator[], decoratorsToRemove: Map): + void { + decorators.forEach(dec => { + const decoratorArray = dec.node.parent !; + if (!decoratorsToRemove.has(decoratorArray)) { + decoratorsToRemove.set(decoratorArray, [dec.node]); + } else { + decoratorsToRemove.get(decoratorArray) !.push(dec.node); + } + }); + } + + /** + * Get the map from the source (note whether it is inline or external) + */ + protected extractSourceMap(file: ts.SourceFile): SourceMapInfo { + const inline = commentRegex.test(file.text); + const external = mapFileCommentRegex.test(file.text); + + if (inline) { + const inlineSourceMap = fromSource(file.text); + return { + source: removeComments(file.text).replace(/\n\n$/, '\n'), + map: inlineSourceMap, + isInline: true, + }; + } else if (external) { + let externalSourceMap: SourceMapConverter|null = null; + try { + externalSourceMap = fromMapFileSource(file.text, dirname(file.fileName)); + } catch (e) { + console.warn(e); + } + return { + source: removeMapFileComments(file.text).replace(/\n\n$/, '\n'), + map: externalSourceMap, + isInline: false, + }; + } else { + return {source: file.text, map: null, isInline: false}; + } + } + + /** + * Merge the input and output source-maps, replacing the source-map comment in the output file + * with an appropriate source-map comment pointing to the merged source-map. + */ + protected renderSourceAndMap( + file: AnalyzedFile, input: SourceMapInfo, output: MagicString, + outputPath: string): RenderResult { + const outputMapPath = `${outputPath}.map`; + const outputMap = output.generateMap({ + source: file.sourceFile.fileName, + includeContent: true, + // hires: true // TODO: This results in accurate but huge sourcemaps. Instead we should fix + // the merge algorithm. + }); + + // we must set this after generation as magic string does "manipulation" on the path + outputMap.file = outputPath; + + const mergedMap = + mergeSourceMaps(input.map && input.map.toObject(), JSON.parse(outputMap.toString())); + + if (input.isInline) { + return { + file, + source: {path: outputPath, contents: `${output.toString()}\n${mergedMap.toComment()}`}, + map: null + }; + } else { + return { + file, + source: { + path: outputPath, + contents: `${output.toString()}\n${generateMapFileComment(outputMapPath)}` + }, + map: {path: outputMapPath, contents: mergedMap.toJSON()} + }; + } + } +} + +/** + * Merge the two specified source-maps into a single source-map that hides the intermediate + * source-map. + * E.g. Consider these mappings: + * + * ``` + * OLD_SRC -> OLD_MAP -> INTERMEDIATE_SRC -> NEW_MAP -> NEW_SRC + * ``` + * + * this will be replaced with: + * + * ``` + * OLD_SRC -> MERGED_MAP -> NEW_SRC + * ``` + */ +export function mergeSourceMaps( + oldMap: RawSourceMap | null, newMap: RawSourceMap): SourceMapConverter { + if (!oldMap) { + return fromObject(newMap); + } + const oldMapConsumer = new SourceMapConsumer(oldMap); + const newMapConsumer = new SourceMapConsumer(newMap); + const mergedMapGenerator = SourceMapGenerator.fromSourceMap(newMapConsumer); + mergedMapGenerator.applySourceMap(oldMapConsumer); + const merged = fromJSON(mergedMapGenerator.toString()); + return merged; +} + +/** + * Render the definitions as source code for the given class. + * @param sourceFile The file containing the class to process. + * @param clazz The class whose definitions are to be rendered. + * @param compilation The results of analyzing the class - this is used to generate the rendered + * definitions. + * @param imports An object that tracks the imports that are needed by the rendered definitions. + */ +export function renderDefinitions( + sourceFile: ts.SourceFile, analyzedClass: AnalyzedClass, imports: ImportManager): string { + const printer = ts.createPrinter(); + const name = (analyzedClass.declaration as ts.NamedDeclaration).name !; + const definitions = + analyzedClass.compilation + .map( + c => c.statements.map(statement => translateStatement(statement, imports)) + .concat(translateStatement( + createAssignmentStatement(name, c.name, c.initializer), imports)) + .map( + statement => + printer.printNode(ts.EmitHint.Unspecified, statement, sourceFile)) + .join('\n')) + .join('\n'); + return definitions; +} + +/** + * Create an Angular AST statement node that contains the assignment of the + * compiled decorator to be applied to the class. + * @param analyzedClass The info about the class whose statement we want to create. + */ +function createAssignmentStatement( + receiverName: ts.DeclarationName, propName: string, initializer: Expression): Statement { + const receiver = new WrappedNodeExpr(receiverName); + return new WritePropExpr(receiver, propName, initializer).toStmt(); +} diff --git a/packages/compiler-cli/src/ngcc/test/rendering/esm2015_renderer_spec.ts b/packages/compiler-cli/src/ngcc/test/rendering/esm2015_renderer_spec.ts new file mode 100644 index 000000000000..002921aa37c3 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/test/rendering/esm2015_renderer_spec.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; +import MagicString from 'magic-string'; +import {makeProgram} from '../helpers/utils'; +import {Analyzer} from '../../src/analyzer'; +import {Esm2015ReflectionHost} from '../../src/host/esm2015_host'; +import {Esm2015FileParser} from '../../src/parsing/esm2015_parser'; +import {Esm2015Renderer} from '../../src/rendering/esm2015_renderer'; + +function setup(file: {name: string, contents: string}) { + const program = makeProgram(file); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const parser = new Esm2015FileParser(program, host); + const analyzer = new Analyzer(program.getTypeChecker(), host); + const renderer = new Esm2015Renderer(host); + return {analyzer, host, parser, program, renderer}; +} + +function analyze(parser: Esm2015FileParser, analyzer: Analyzer, file: ts.SourceFile) { + const parsedFiles = parser.parseFile(file); + return parsedFiles.map(file => analyzer.analyzeFile(file))[0]; +} + + +describe('Esm2015Renderer', () => { + + describe('addImports', () => { + it('should insert the given imports at the start of the source file', () => { + const PROGRAM = { + name: 'some/file.js', + contents: ` +/* A copyright notice */ +import {Directive} from '@angular/core'; +export class A {} +A.decorators = [ + { type: Directive, args: [{ selector: '[a]' }] }, + { type: Other } +]; +// Some other content` + }; + const {renderer} = setup(PROGRAM); + const output = new MagicString(PROGRAM.contents); + renderer.addImports( + output, [{name: '@angular/core', as: 'i0'}, {name: '@angular/common', as: 'i1'}]); + expect(output.toString()) + .toEqual( + `import * as i0 from '@angular/core';\n` + + `import * as i1 from '@angular/common';\n` + PROGRAM.contents); + }); + }); + + + describe('addDefinitions', () => { + it('should insert the definitions directly after the class declaration', () => { + const PROGRAM = { + name: 'some/file.js', + contents: ` +/* A copyright notice */ +import {Directive} from '@angular/core'; +export class A {} +A.decorators = [ + { type: Directive, args: [{ selector: '[a]' }] }, + { type: Other } +]; +// Some other content` + }; + const {analyzer, parser, program, renderer} = setup(PROGRAM); + const analyzedFile = analyze(parser, analyzer, program.getSourceFile(PROGRAM.name) !); + const output = new MagicString(PROGRAM.contents); + renderer.addDefinitions(output, analyzedFile.analyzedClasses[0], 'SOME DEFINITION TEXT'); + expect(output.toString()).toEqual(` +/* A copyright notice */ +import {Directive} from '@angular/core'; +export class A {} +SOME DEFINITION TEXT +A.decorators = [ + { type: Directive, args: [{ selector: '[a]' }] }, + { type: Other } +]; +// Some other content`); + }); + + }); + + + describe('removeDecorators', () => { + + it('should delete the decorator (and following comma) that was matched in the analysis', () => { + const PROGRAM = { + name: 'some/file.js', + contents: ` +/* A copyright notice */ +import {Directive} from '@angular/core'; +export class A {} +A.decorators = [ + { type: Directive, args: [{ selector: '[a]' }] }, + { type: Other } +]; +// Some other content` + }; + const {analyzer, parser, program, renderer} = setup(PROGRAM); + const analyzedFile = analyze(parser, analyzer, program.getSourceFile(PROGRAM.name) !); + const output = new MagicString(PROGRAM.contents); + const analyzedClass = analyzedFile.analyzedClasses[0]; + const decoratorsToRemove = new Map(); + decoratorsToRemove.set( + analyzedClass.decorators[0].node.parent !, [analyzedClass.decorators[0].node]); + renderer.removeDecorators(output, decoratorsToRemove); + expect(output.toString()).toEqual(` +/* A copyright notice */ +import {Directive} from '@angular/core'; +export class A {} +A.decorators = [ + { type: Other } +]; +// Some other content`); + }); + + + it('should delete the decorator (but cope with no trailing comma) that was matched in the analysis', + () => { + const PROGRAM = { + name: 'some/file.js', + contents: ` +/* A copyright notice */ +import {Directive} from '@angular/core'; +export class A {} +A.decorators = [ + { type: Other }, + { type: Directive, args: [{ selector: '[a]' }] } +]; +// Some other content` + }; + const {analyzer, parser, program, renderer} = setup(PROGRAM); + const analyzedFile = analyze(parser, analyzer, program.getSourceFile(PROGRAM.name) !); + const output = new MagicString(PROGRAM.contents); + const analyzedClass = analyzedFile.analyzedClasses[0]; + const decoratorsToRemove = new Map(); + decoratorsToRemove.set( + analyzedClass.decorators[0].node.parent !, [analyzedClass.decorators[1].node]); + renderer.removeDecorators(output, decoratorsToRemove); + expect(output.toString()).toEqual(` +/* A copyright notice */ +import {Directive} from '@angular/core'; +export class A {} +A.decorators = [ + { type: Other }, +]; +// Some other content`); + }); + + + it('should delete the decorator (and its container if there are not other decorators left) that was matched in the analysis', + () => { + const PROGRAM = { + name: 'some/file.js', + contents: ` +/* A copyright notice */ +import {Directive} from '@angular/core'; +export class A {} +A.decorators = [ + { type: Directive, args: [{ selector: '[a]' }] } +]; +// Some other content` + }; + const {analyzer, parser, program, renderer} = setup(PROGRAM); + const analyzedFile = analyze(parser, analyzer, program.getSourceFile(PROGRAM.name) !); + const output = new MagicString(PROGRAM.contents); + const analyzedClass = analyzedFile.analyzedClasses[0]; + const decoratorsToRemove = new Map(); + decoratorsToRemove.set( + analyzedClass.decorators[0].node.parent !, [analyzedClass.decorators[0].node]); + renderer.removeDecorators(output, decoratorsToRemove); + expect(output.toString()).toEqual(` +/* A copyright notice */ +import {Directive} from '@angular/core'; +export class A {} +// Some other content`); + }); + + }); +}); diff --git a/packages/compiler-cli/src/ngcc/test/rendering/esm5_renderer_spec.ts b/packages/compiler-cli/src/ngcc/test/rendering/esm5_renderer_spec.ts new file mode 100644 index 000000000000..b3ab70ac32f5 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/test/rendering/esm5_renderer_spec.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; +import MagicString from 'magic-string'; +import {makeProgram} from '../helpers/utils'; +import {Analyzer} from '../../src/analyzer'; +import {Esm5ReflectionHost} from '../../src/host/esm5_host'; +import {Esm5FileParser} from '../../src/parsing/esm5_parser'; +import {Esm5Renderer} from '../../src/rendering/esm5_renderer'; + +function setup(file: {name: string, contents: string}) { + const program = makeProgram(file); + const host = new Esm5ReflectionHost(program.getTypeChecker()); + const parser = new Esm5FileParser(program, host); + const analyzer = new Analyzer(program.getTypeChecker(), host); + const renderer = new Esm5Renderer(host); + return {analyzer, host, parser, program, renderer}; +} + +function analyze(parser: Esm5FileParser, analyzer: Analyzer, file: ts.SourceFile) { + const parsedFiles = parser.parseFile(file); + return parsedFiles.map(file => analyzer.analyzeFile(file))[0]; +} + +describe('Esm5Renderer', () => { + + describe('addImports', () => { + it('should insert the given imports at the start of the source file', () => { + const PROGRAM = { + name: 'some/file.js', + contents: ` +/* A copyright notice */ +import {Directive} from '@angular/core'; +var A = (function() { + function A() {} + A.decorators = [ + { type: Directive, args: [{ selector: '[a]' }] }, + { type: Other } + ]; + return A; +}()); +// Some other content +export {A};` + }; + const {renderer} = setup(PROGRAM); + const output = new MagicString(PROGRAM.contents); + renderer.addImports( + output, [{name: '@angular/core', as: 'i0'}, {name: '@angular/common', as: 'i1'}]); + expect(output.toString()) + .toEqual( + `import * as i0 from '@angular/core';\n` + + `import * as i1 from '@angular/common';\n` + PROGRAM.contents); + }); + }); + + + describe('addDefinitions', () => { + it('should insert the definitions directly after the class declaration', () => { + const PROGRAM = { + name: 'some/file.js', + contents: ` +/* A copyright notice */ +import {Directive} from '@angular/core'; +var A = (function() { + function A() {} + A.decorators = [ + { type: Directive, args: [{ selector: '[a]' }] }, + { type: Other } + ]; + return A; +}()); +// Some other content +export {A};` + }; + const {analyzer, parser, program, renderer} = setup(PROGRAM); + const analyzedFile = analyze(parser, analyzer, program.getSourceFile(PROGRAM.name) !); + const output = new MagicString(PROGRAM.contents); + renderer.addDefinitions(output, analyzedFile.analyzedClasses[0], 'SOME DEFINITION TEXT'); + expect(output.toString()).toEqual(` +/* A copyright notice */ +import {Directive} from '@angular/core'; +var A = (function() { + function A() {} +SOME DEFINITION TEXT + A.decorators = [ + { type: Directive, args: [{ selector: '[a]' }] }, + { type: Other } + ]; + return A; +}()); +// Some other content +export {A};`); + }); + + }); + + + describe('removeDecorators', () => { + + it('should delete the decorator (and following comma) that was matched in the analysis', () => { + const PROGRAM = { + name: 'some/file.js', + contents: ` +/* A copyright notice */ +import {Directive} from '@angular/core'; +var A = (function() { + function A() {} + A.decorators = [ + { type: Directive, args: [{ selector: '[a]' }] }, + { type: Other } + ]; + return A; +}()); +// Some other content +export {A};` + }; + const {analyzer, parser, program, renderer} = setup(PROGRAM); + const analyzedFile = analyze(parser, analyzer, program.getSourceFile(PROGRAM.name) !); + const output = new MagicString(PROGRAM.contents); + const analyzedClass = analyzedFile.analyzedClasses[0]; + const decoratorsToRemove = new Map(); + decoratorsToRemove.set( + analyzedClass.decorators[0].node.parent !, [analyzedClass.decorators[0].node]); + renderer.removeDecorators(output, decoratorsToRemove); + expect(output.toString()).toEqual(` +/* A copyright notice */ +import {Directive} from '@angular/core'; +var A = (function() { + function A() {} + A.decorators = [ + { type: Other } + ]; + return A; +}()); +// Some other content +export {A};`); + }); + + + it('should delete the decorator (but cope with no trailing comma) that was matched in the analysis', + () => { + const PROGRAM = { + name: 'some/file.js', + contents: ` +/* A copyright notice */ +import {Directive} from '@angular/core'; +var A = (function() { + function A() {} + A.decorators = [ + { type: Other }, + { type: Directive, args: [{ selector: '[a]' }] } + ]; + return A; +}()); +// Some other content +export {A};` + }; + const {analyzer, parser, program, renderer} = setup(PROGRAM); + const analyzedFile = analyze(parser, analyzer, program.getSourceFile(PROGRAM.name) !); + const output = new MagicString(PROGRAM.contents); + const analyzedClass = analyzedFile.analyzedClasses[0]; + const decoratorsToRemove = new Map(); + decoratorsToRemove.set( + analyzedClass.decorators[0].node.parent !, [analyzedClass.decorators[1].node]); + renderer.removeDecorators(output, decoratorsToRemove); + expect(output.toString()).toEqual(` +/* A copyright notice */ +import {Directive} from '@angular/core'; +var A = (function() { + function A() {} + A.decorators = [ + { type: Other }, + ]; + return A; +}()); +// Some other content +export {A};`); + }); + + + it('should delete the decorator (and its container if there are not other decorators left) that was matched in the analysis', + () => { + const PROGRAM = { + name: 'some/file.js', + contents: ` +/* A copyright notice */ +import {Directive} from '@angular/core'; +var A = (function() { + function A() {} + A.decorators = [ + { type: Directive, args: [{ selector: '[a]' }] } + ]; + return A; +}()); +// Some other content +export {A};` + }; + const {analyzer, parser, program, renderer} = setup(PROGRAM); + const analyzedFile = analyze(parser, analyzer, program.getSourceFile(PROGRAM.name) !); + const output = new MagicString(PROGRAM.contents); + const analyzedClass = analyzedFile.analyzedClasses[0]; + const decoratorsToRemove = new Map(); + decoratorsToRemove.set( + analyzedClass.decorators[0].node.parent !, [analyzedClass.decorators[0].node]); + renderer.removeDecorators(output, decoratorsToRemove); + expect(output.toString()).toEqual(` +/* A copyright notice */ +import {Directive} from '@angular/core'; +var A = (function() { + function A() {} + return A; +}()); +// Some other content +export {A};`); + }); + + }); +}); diff --git a/packages/compiler-cli/src/ngcc/test/rendering/renderer_spec.ts b/packages/compiler-cli/src/ngcc/test/rendering/renderer_spec.ts new file mode 100644 index 000000000000..3daf39c882b7 --- /dev/null +++ b/packages/compiler-cli/src/ngcc/test/rendering/renderer_spec.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as fs from 'fs'; +import * as ts from 'typescript'; + +import MagicString from 'magic-string'; +import {fromObject, generateMapFileComment} from 'convert-source-map'; +import {makeProgram} from '../helpers/utils'; +import {AnalyzedClass, Analyzer} from '../../src/analyzer'; +import {Esm2015ReflectionHost} from '../../src/host/esm2015_host'; +import {Esm2015FileParser} from '../../src/parsing/esm2015_parser'; +import {Renderer} from '../../src/rendering/renderer'; + +class TestRenderer extends Renderer { + addImports(output: MagicString, imports: {name: string, as: string}[]) { + output.prepend('\n// ADD IMPORTS\n'); + } + addDefinitions(output: MagicString, analyzedClass: AnalyzedClass, definitions: string) { + output.prepend('\n// ADD DEFINITIONS\n'); + } + removeDecorators(output: MagicString, decoratorsToRemove: Map) { + output.prepend('\n// REMOVE DECORATORS\n'); + } +} + +function createTestRenderer() { + const renderer = new TestRenderer(); + spyOn(renderer, 'addImports').and.callThrough(); + spyOn(renderer, 'addDefinitions').and.callThrough(); + spyOn(renderer, 'removeDecorators').and.callThrough(); + return renderer as jasmine.SpyObj; +} + +function analyze(file: {name: string, contents: string}) { + const program = makeProgram(file); + const host = new Esm2015ReflectionHost(program.getTypeChecker()); + const parser = new Esm2015FileParser(program, host); + const analyzer = new Analyzer(program.getTypeChecker(), host); + + const parsedFiles = parser.parseFile(program.getSourceFile(file.name) !); + return parsedFiles.map(file => analyzer.analyzeFile(file)); +} + +describe('Renderer', () => { + const INPUT_PROGRAM = { + name: '/file.js', + contents: + `import { Directive } from '@angular/core';\nexport class A {\n foo(x) {\n return x;\n }\n}\nA.decorators = [\n { type: Directive, args: [{ selector: '[a]' }] }\n];\n` + }; + const INPUT_PROGRAM_MAP = fromObject({ + 'version': 3, + 'file': '/file.js', + 'sourceRoot': '', + 'sources': ['/file.ts'], + 'names': [], + 'mappings': + 'AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC1C,MAAM;IACF,GAAG,CAAC,CAAS;QACT,OAAO,CAAC,CAAC;IACb,CAAC;;AACM,YAAU,GAAG;IAChB,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,EAAE;CACnD,CAAC', + 'sourcesContent': [ + 'import { Directive } from \'@angular/core\';\nexport class A {\n foo(x: string): string {\n return x;\n }\n static decorators = [\n { type: Directive, args: [{ selector: \'[a]\' }] }\n ];\n}' + ] + }); + const RENDERED_CONTENTS = + `\n// REMOVE DECORATORS\n\n// ADD IMPORTS\n\n// ADD DEFINITIONS\n` + INPUT_PROGRAM.contents; + const OUTPUT_PROGRAM_MAP = fromObject({ + 'version': 3, + 'file': '/output_file.js', + 'sources': ['/file.js'], + 'sourcesContent': [ + 'import { Directive } from \'@angular/core\';\nexport class A {\n foo(x) {\n return x;\n }\n}\nA.decorators = [\n { type: Directive, args: [{ selector: \'[a]\' }] }\n];\n' + ], + 'names': [], + 'mappings': ';;;;;;AAAA;;;;;;;;;' + }); + + const MERGED_OUTPUT_PROGRAM_MAP = fromObject({ + 'version': 3, + 'sources': ['/file.ts'], + 'names': [], + 'mappings': ';;;;;;AAAA', + 'file': '/output_file.js', + 'sourcesContent': [ + 'import { Directive } from \'@angular/core\';\nexport class A {\n foo(x: string): string {\n return x;\n }\n static decorators = [\n { type: Directive, args: [{ selector: \'[a]\' }] }\n ];\n}' + ] + }); + + describe('renderFile()', () => { + it('should render the modified contents; and a new map file, if the original provided no map file.', + () => { + const renderer = createTestRenderer(); + const analyzedFiles = analyze(INPUT_PROGRAM); + const result = renderer.renderFile(analyzedFiles[0], '/output_file.js'); + expect(result.source.path).toEqual('/output_file.js'); + expect(result.source.contents) + .toEqual(RENDERED_CONTENTS + '\n' + generateMapFileComment('/output_file.js.map')); + expect(result.map !.path).toEqual('/output_file.js.map'); + expect(result.map !.contents).toEqual(OUTPUT_PROGRAM_MAP.toJSON()); + }); + + it('should call addImports with the source code and info about the core Angular library.', + () => { + const renderer = createTestRenderer(); + const analyzedFiles = analyze(INPUT_PROGRAM); + renderer.renderFile(analyzedFiles[0], '/output_file.js'); + expect(renderer.addImports.calls.first().args[0].toString()).toEqual(RENDERED_CONTENTS); + expect(renderer.addImports.calls.first().args[1]).toEqual([ + {name: '@angular/core', as: 'ɵngcc0'} + ]); + }); + + it('should call addDefinitions with the source code, the analyzed class and the renderered definitions.', + () => { + const renderer = createTestRenderer(); + const analyzedFile = analyze(INPUT_PROGRAM)[0]; + renderer.renderFile(analyzedFile, '/output_file.js'); + expect(renderer.addDefinitions.calls.first().args[0].toString()) + .toEqual(RENDERED_CONTENTS); + expect(renderer.addDefinitions.calls.first().args[1]) + .toBe(analyzedFile.analyzedClasses[0]); + expect(renderer.addDefinitions.calls.first().args[2]) + .toEqual( + `A.ngDirectiveDef = ɵngcc0.ɵdefineDirective({ type: A, selectors: [["", "a", ""]], factory: function A_Factory() { return new A(); } });`); + }); + + it('should call removeDecorators with the source code, a map of class decorators that have been analyzed', + () => { + const renderer = createTestRenderer(); + const analyzedFile = analyze(INPUT_PROGRAM)[0]; + renderer.renderFile(analyzedFile, '/output_file.js'); + expect(renderer.removeDecorators.calls.first().args[0].toString()) + .toEqual(RENDERED_CONTENTS); + + // Each map key is the TS node of the decorator container + // Each map value is an array of TS nodes that are the decorators to remove + const map = renderer.removeDecorators.calls.first().args[1] as Map; + const keys = Array.from(map.keys()); + expect(keys.length).toEqual(1); + expect(keys[0].getText()) + .toEqual(`[\n { type: Directive, args: [{ selector: '[a]' }] }\n]`); + const values = Array.from(map.values()); + expect(values.length).toEqual(1); + expect(values[0].length).toEqual(1); + expect(values[0][0].getText()).toEqual(`{ type: Directive, args: [{ selector: '[a]' }] }`); + }); + + it('should merge any inline source map from the original file and write the output as an inline source map', + () => { + const renderer = createTestRenderer(); + const analyzedFiles = analyze({ + ...INPUT_PROGRAM, + contents: INPUT_PROGRAM.contents + '\n' + INPUT_PROGRAM_MAP.toComment() + }); + const result = renderer.renderFile(analyzedFiles[0], '/output_file.js'); + expect(result.source.path).toEqual('/output_file.js'); + expect(result.source.contents) + .toEqual(RENDERED_CONTENTS + '\n' + MERGED_OUTPUT_PROGRAM_MAP.toComment()); + expect(result.map).toBe(null); + }); + + it('should merge any external source map from the original file and write the output to an external source map', + () => { + // Mock out reading the map file from disk + const readFileSyncSpy = + spyOn(fs, 'readFileSync').and.returnValue(INPUT_PROGRAM_MAP.toJSON()); + const renderer = createTestRenderer(); + const analyzedFiles = analyze({ + ...INPUT_PROGRAM, + contents: INPUT_PROGRAM.contents + '\n//# sourceMappingURL=file.js.map' + }); + const result = renderer.renderFile(analyzedFiles[0], '/output_file.js'); + expect(result.source.path).toEqual('/output_file.js'); + expect(result.source.contents) + .toEqual(RENDERED_CONTENTS + '\n' + generateMapFileComment('/output_file.js.map')); + expect(result.map !.path).toEqual('/output_file.js.map'); + expect(result.map !.contents).toEqual(MERGED_OUTPUT_PROGRAM_MAP.toJSON()); + }); + }); +}); \ No newline at end of file From 8f30c4f6dedce4de9d543e029dd5870fb0c4b4a1 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 10:24:32 +0100 Subject: [PATCH 14/15] feat(ivy): implement initial ngcc package transformer --- packages/compiler-cli/src/ngcc/src/main.ts | 7 + .../ngcc/src/transform/package_transformer.ts | 121 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 packages/compiler-cli/src/ngcc/src/transform/package_transformer.ts diff --git a/packages/compiler-cli/src/ngcc/src/main.ts b/packages/compiler-cli/src/ngcc/src/main.ts index b20388f7e463..fece2d136977 100644 --- a/packages/compiler-cli/src/ngcc/src/main.ts +++ b/packages/compiler-cli/src/ngcc/src/main.ts @@ -6,9 +6,16 @@ * found in the LICENSE file at https://angular.io/license */ import {resolve} from 'path'; +import {PackageTransformer} from './transform/package_transformer'; export function mainNgcc(args: string[]): number { const packagePath = resolve(args[0]); + // TODO: find all the package tyoes to transform + // TODO: error/warning logging/handling etc + + const transformer = new PackageTransformer(); + transformer.transform(packagePath, 'fesm2015'); + return 0; } diff --git a/packages/compiler-cli/src/ngcc/src/transform/package_transformer.ts b/packages/compiler-cli/src/ngcc/src/transform/package_transformer.ts new file mode 100644 index 000000000000..ab974015c06a --- /dev/null +++ b/packages/compiler-cli/src/ngcc/src/transform/package_transformer.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {writeFileSync} from 'fs'; +import {dirname, relative, resolve} from 'path'; +import {mkdir} from 'shelljs'; +import * as ts from 'typescript'; + +import {Analyzer} from '../analyzer'; +import {Esm2015ReflectionHost} from '../host/esm2015_host'; +import {Esm5ReflectionHost} from '../host/esm5_host'; +import {NgccReflectionHost} from '../host/ngcc_host'; +import {Esm2015FileParser} from '../parsing/esm2015_parser'; +import {Esm5FileParser} from '../parsing/esm5_parser'; +import {FileParser} from '../parsing/file_parser'; +import {getEntryPoints} from '../parsing/utils'; +import {Esm2015Renderer} from '../rendering/esm2015_renderer'; +import {Esm5Renderer} from '../rendering/esm5_renderer'; +import {FileInfo, Renderer} from '../rendering/renderer'; + + +/** + * A Package is stored in a directory on disk and that directory can contain one or more package + formats - e.g. fesm2015, UMD, etc. + * + * Each of these formats exposes one or more entry points, which are source files that need to be + * parsed to identify the decorated exported classes that need to be analyzed and compiled by one or + * more `DecoratorHandler` objects. + * + * Each entry point to a package is identified by a `SourceFile` that can be parsed and analyzed + * to identify classes that need to be transformed; and then finally rendered and written to disk. + + * The actual file which needs to be transformed depends upon the package format. + * + * - Flat file packages have all the classes in a single file. + * - Other packages may re-export classes from other non-entry point files. + * - Some formats may contain multiple "modules" in a single file. + */ +export class PackageTransformer { + transform(packagePath: string, format: string): void { + const sourceNodeModules = this.findNodeModulesPath(packagePath); + const targetNodeModules = sourceNodeModules.replace(/node_modules$/, 'node_modules_ngtsc'); + const entryPointPaths = getEntryPoints(packagePath, format); + entryPointPaths.forEach(entryPointPath => { + const options: ts.CompilerOptions = {allowJs: true, rootDir: entryPointPath}; + const host = ts.createCompilerHost(options); + const packageProgram = ts.createProgram([entryPointPath], options, host); + const entryPointFile = packageProgram.getSourceFile(entryPointPath) !; + const typeChecker = packageProgram.getTypeChecker(); + + const reflectionHost = this.getHost(format, packageProgram); + const parser = this.getFileParser(format, packageProgram, reflectionHost); + const analyzer = new Analyzer(typeChecker, reflectionHost); + const renderer = this.getRenderer(format, packageProgram, reflectionHost); + + const parsedFiles = parser.parseFile(entryPointFile); + parsedFiles.forEach(parsedFile => { + const analyzedFile = analyzer.analyzeFile(parsedFile); + const targetPath = resolve( + targetNodeModules, relative(sourceNodeModules, analyzedFile.sourceFile.fileName)); + const {source, map} = renderer.renderFile(analyzedFile, targetPath); + this.writeFile(source); + if (map) { + this.writeFile(map); + } + }); + }); + } + + getHost(format: string, program: ts.Program): NgccReflectionHost { + switch (format) { + case 'esm2015': + case 'fesm2015': + return new Esm2015ReflectionHost(program.getTypeChecker()); + case 'fesm5': + return new Esm5ReflectionHost(program.getTypeChecker()); + default: + throw new Error(`Relection host for "${format}" not yet implemented.`); + } + } + + getFileParser(format: string, program: ts.Program, host: NgccReflectionHost): FileParser { + switch (format) { + case 'esm2015': + case 'fesm2015': + return new Esm2015FileParser(program, host); + case 'fesm5': + return new Esm5FileParser(program, host); + default: + throw new Error(`File parser for "${format}" not yet implemented.`); + } + } + + getRenderer(format: string, program: ts.Program, host: NgccReflectionHost): Renderer { + switch (format) { + case 'esm2015': + case 'fesm2015': + return new Esm2015Renderer(host); + case 'fesm5': + return new Esm5Renderer(host); + default: + throw new Error(`Renderer for "${format}" not yet implemented.`); + } + } + + findNodeModulesPath(src: string): string { + while (src && !/node_modules$/.test(src)) { + src = dirname(src); + } + return src; + } + + writeFile(file: FileInfo): void { + mkdir('-p', dirname(file.path)); + writeFileSync(file.path, file.contents, 'utf8'); + } +} From 3d81e06427c7292faef02d9a6fa3ecc54a777f71 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Wed, 25 Jul 2018 06:28:54 +0100 Subject: [PATCH 15/15] refactor(ivy): do not deep import from ngtsc into ngcc --- packages/compiler-cli/src/ngcc/src/host/esm5_host.ts | 5 ++--- packages/compiler-cli/src/ngcc/src/rendering/renderer.ts | 2 +- packages/compiler-cli/src/ngtsc/transform/index.ts | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/compiler-cli/src/ngcc/src/host/esm5_host.ts b/packages/compiler-cli/src/ngcc/src/host/esm5_host.ts index a84bc471a76e..b79b13d0de20 100644 --- a/packages/compiler-cli/src/ngcc/src/host/esm5_host.ts +++ b/packages/compiler-cli/src/ngcc/src/host/esm5_host.ts @@ -7,9 +7,8 @@ */ import * as ts from 'typescript'; -import {Decorator} from '../../../ngtsc/host'; -import {ClassMember, ClassMemberKind} from '../../../ngtsc/host/src/reflection'; -import {reflectObjectLiteral} from '../../../ngtsc/metadata/src/reflector'; +import {ClassMember, ClassMemberKind, Decorator} from '../../../ngtsc/host'; +import {reflectObjectLiteral} from '../../../ngtsc/metadata'; import {CONSTRUCTOR_PARAMS, Esm2015ReflectionHost, getPropertyValueFromSymbol} from './esm2015_host'; /** diff --git a/packages/compiler-cli/src/ngcc/src/rendering/renderer.ts b/packages/compiler-cli/src/ngcc/src/rendering/renderer.ts index 8d33aa3f15df..55a8b9d5fe6a 100644 --- a/packages/compiler-cli/src/ngcc/src/rendering/renderer.ts +++ b/packages/compiler-cli/src/ngcc/src/rendering/renderer.ts @@ -14,7 +14,7 @@ import {SourceMapConsumer, SourceMapGenerator, RawSourceMap} from 'source-map'; import {Expression, Statement, WrappedNodeExpr, WritePropExpr} from '@angular/compiler'; import {AnalyzedClass, AnalyzedFile} from '../analyzer'; import {Decorator} from '../../../ngtsc/host'; -import {ImportManager, translateStatement} from '../../../ngtsc/transform/src/translator'; +import {ImportManager, translateStatement} from '../../../ngtsc/transform'; interface SourceMapInfo { source: string; diff --git a/packages/compiler-cli/src/ngtsc/transform/index.ts b/packages/compiler-cli/src/ngtsc/transform/index.ts index ccf8eff5bd9e..00eea48b1350 100644 --- a/packages/compiler-cli/src/ngtsc/transform/index.ts +++ b/packages/compiler-cli/src/ngtsc/transform/index.ts @@ -9,3 +9,4 @@ export * from './src/api'; export {IvyCompilation} from './src/compilation'; export {ivyTransformFactory} from './src/transform'; +export {ImportManager, translateStatement} from './src/translator'; \ No newline at end of file