From 2f70e9049303b2b8c3dfc32a7ea1bc2c3f9be119 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 08:53:16 +0100 Subject: [PATCH] feat(ivy): implement esm2015 and esm5 file parsers (#24897) PR Close #24897 --- .../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 0000000000..a476365fc8 --- /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 0000000000..e4c91de628 --- /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 0000000000..4b73263629 --- /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 0000000000..c6cad7457d --- /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 0000000000..117c460082 --- /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 0000000000..e9270e0d85 --- /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 0000000000..4607d4a9cf --- /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 0000000000..f69ce36a78 --- /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 0000000000..559fcea3f5 --- /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([]); + }); +});