From 5b32aa44868d9e50445f0a1fd06b25b05ea373f9 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 16 Jul 2018 10:23:37 +0100 Subject: [PATCH] feat(ivy): implement esm2015 and esm5 ngcc file renderers (#24897) PR Close #24897 --- .../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 0000000000..a841b0c616 --- /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 0000000000..5f5d7fdec3 --- /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 0000000000..8d33aa3f15 --- /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 0000000000..002921aa37 --- /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 0000000000..b3ab70ac32 --- /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 0000000000..3daf39c882 --- /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