feat(compiler-cli): automatically lower lambda expressions in metadata

This commit is contained in:
Chuck Jazdzewski 2017-07-13 14:25:17 -07:00 committed by Alex Rickabaugh
parent 67dff7bd5d
commit b6c4af6495
9 changed files with 456 additions and 20 deletions

View File

@ -25,8 +25,9 @@ export interface CompilerHostContext extends ts.ModuleResolutionHost {
assumeFileExists(fileName: string): void;
}
export interface MetadataProvider { getMetadata(source: ts.SourceFile): ModuleMetadata|undefined; }
export class CompilerHost implements AotCompilerHost {
protected metadataCollector = new MetadataCollector();
private isGenDirChildOfRootDir: boolean;
protected basePath: string;
private genDir: string;
@ -39,7 +40,8 @@ export class CompilerHost implements AotCompilerHost {
constructor(
protected program: ts.Program, protected options: AngularCompilerOptions,
protected context: CompilerHostContext, collectorOptions?: CollectorOptions) {
protected context: CompilerHostContext, collectorOptions?: CollectorOptions,
protected metadataProvider: MetadataProvider = new MetadataCollector()) {
// normalize the path so that it never ends with '/'.
this.basePath = path.normalize(path.join(this.options.basePath !, '.')).replace(/\\/g, '/');
this.genDir = path.normalize(path.join(this.options.genDir !, '.')).replace(/\\/g, '/');
@ -206,7 +208,7 @@ export class CompilerHost implements AotCompilerHost {
}
const sf = this.getSourceFile(filePath);
const metadata = this.metadataCollector.getMetadata(sf);
const metadata = this.metadataProvider.getMetadata(sf);
return metadata ? [metadata] : [];
}
@ -245,7 +247,7 @@ export class CompilerHost implements AotCompilerHost {
v3Metadata.metadata[prop] = v1Metadata.metadata[prop];
}
const exports = this.metadataCollector.getMetadata(this.getSourceFile(dtsFilePath));
const exports = this.metadataProvider.getMetadata(this.getSourceFile(dtsFilePath));
if (exports) {
for (let prop in exports.metadata) {
if (!v3Metadata.metadata[prop]) {

View File

@ -132,7 +132,7 @@ export class PathMappedCompilerHost extends CompilerHost {
} else {
const sf = this.getSourceFile(rootedPath);
sf.fileName = sf.fileName;
const metadata = this.metadataCollector.getMetadata(sf);
const metadata = this.metadataProvider.getMetadata(sf);
return metadata ? [metadata] : [];
}
}

View File

@ -91,6 +91,10 @@ export interface CompilerOptions extends ts.CompilerOptions {
// Whether to enable support for <template> and the template attribute (true by default)
enableLegacyTemplate?: boolean;
// Whether to enable lowering expressions lambdas and expressions in a reference value
// position.
disableExpressionLowering?: boolean;
}
export interface ModuleFilenameResolver {

View File

@ -1,3 +1,11 @@
/**
* @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 {CompilerHost, CompilerOptions, Program} from './api';

View File

@ -0,0 +1,177 @@
/**
* @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 {CollectorOptions, MetadataCollector, MetadataValue, ModuleMetadata} from '@angular/tsc-wrapped';
import * as ts from 'typescript';
export interface LoweringRequest {
kind: ts.SyntaxKind;
location: number;
end: number;
name: string;
}
export type RequestLocationMap = Map<number, LoweringRequest>;
interface Declaration {
name: string;
node: ts.Node;
}
interface DeclarationInsert {
declarations: Declaration[];
priorTo: ts.Node;
}
function toMap<T, K>(items: T[], select: (item: T) => K): Map<K, T> {
return new Map(items.map<[K, T]>(i => [select(i), i]));
}
function transformSourceFile(
sourceFile: ts.SourceFile, requests: RequestLocationMap,
context: ts.TransformationContext): ts.SourceFile {
const inserts: DeclarationInsert[] = [];
// Calculate the range of intersting locations. The transform will only visit nodes in this
// range to improve the performance on large files.
const locations = Array.from(requests.keys());
const min = Math.min(...locations);
const max = Math.max(...locations);
function visitSourceFile(sourceFile: ts.SourceFile): ts.SourceFile {
function topLevelStatement(node: ts.Node): ts.Node {
const declarations: Declaration[] = [];
function visitNode(node: ts.Node): ts.Node {
const nodeRequest = requests.get(node.pos);
if (nodeRequest && nodeRequest.kind == node.kind && nodeRequest.end == node.end) {
// This node is requested to be rewritten as a reference to the exported name.
// Record that the node needs to be moved to an exported variable with the given name
const name = nodeRequest.name;
declarations.push({name, node});
return ts.createIdentifier(name);
}
if (node.pos <= max && node.end >= min) return ts.visitEachChild(node, visitNode, context);
return node;
}
const result = ts.visitEachChild(node, visitNode, context);
if (declarations.length) {
inserts.push({priorTo: result, declarations})
}
return result;
}
const traversedSource = ts.visitEachChild(sourceFile, topLevelStatement, context);
if (inserts.length) {
// Insert the declarations before the rewritten statement that references them.
const insertMap = toMap(inserts, i => i.priorTo);
const newStatements: ts.Statement[] = [...traversedSource.statements];
for (let i = newStatements.length; i >= 0; i--) {
const statement = newStatements[i];
const insert = insertMap.get(statement);
if (insert) {
const declarations = insert.declarations.map(
i => ts.createVariableDeclaration(
i.name, /* type */ undefined, i.node as ts.Expression));
const statement = ts.createVariableStatement(
/* modifiers */ undefined,
ts.createVariableDeclarationList(declarations, ts.NodeFlags.Const));
newStatements.splice(i, 0, statement);
}
}
// Insert an exports clause to export the declarations
newStatements.push(ts.createExportDeclaration(
/* decorators */ undefined,
/* modifiers */ undefined,
ts.createNamedExports(
inserts
.reduce(
(accumulator, insert) => [...accumulator, ...insert.declarations],
[] as Declaration[])
.map(
declaration => ts.createExportSpecifier(
/* propertyName */ undefined, declaration.name)))));
return ts.updateSourceFileNode(traversedSource, newStatements);
}
return traversedSource;
}
return visitSourceFile(sourceFile);
}
export function getExpressionLoweringTransformFactory(requestsMap: RequestsMap):
(context: ts.TransformationContext) => (sourceFile: ts.SourceFile) => ts.SourceFile {
// Return the factory
return (context: ts.TransformationContext) => (sourceFile: ts.SourceFile): ts.SourceFile => {
const requests = requestsMap.getRequests(sourceFile);
if (requests && requests.size) {
return transformSourceFile(sourceFile, requests, context)
}
return sourceFile;
};
}
export interface RequestsMap { getRequests(sourceFile: ts.SourceFile): RequestLocationMap; }
interface MetadataAndLoweringRequests {
metadata: ModuleMetadata|undefined;
requests: RequestLocationMap;
}
export class LowerMetadataCache implements RequestsMap {
private collector: MetadataCollector;
private metadataCache = new Map<string, MetadataAndLoweringRequests>();
constructor(options: CollectorOptions, private strict?: boolean) {
this.collector = new MetadataCollector(options);
}
getMetadata(sourceFile: ts.SourceFile): ModuleMetadata|undefined {
return this.ensureMetadataAndRequests(sourceFile).metadata;
}
getRequests(sourceFile: ts.SourceFile): RequestLocationMap {
return this.ensureMetadataAndRequests(sourceFile).requests;
}
private ensureMetadataAndRequests(sourceFile: ts.SourceFile): MetadataAndLoweringRequests {
let result = this.metadataCache.get(sourceFile.fileName);
if (!result) {
result = this.getMetadataAndRequests(sourceFile);
this.metadataCache.set(sourceFile.fileName, result);
}
return result;
}
private getMetadataAndRequests(sourceFile: ts.SourceFile): MetadataAndLoweringRequests {
let identNumber = 0;
const freshIdent = () => '\u0275' + identNumber++;
const requests = new Map<number, LoweringRequest>();
const replaceNode =
(node: ts.Node) => {
const name = freshIdent();
requests.set(node.pos, {name, kind: node.kind, location: node.pos, end: node.end});
return {__symbolic: 'reference', name};
}
const substituteExpression = (value: MetadataValue, node: ts.Node): MetadataValue => {
if (node.kind === ts.SyntaxKind.ArrowFunction ||
node.kind === ts.SyntaxKind.FunctionExpression) {
return replaceNode(node);
}
return value;
};
const metadata = this.collector.getMetadata(sourceFile, this.strict, substituteExpression);
return {metadata, requests};
}
}

View File

@ -1,3 +1,11 @@
/**
* @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 path from 'path';
import * as ts from 'typescript';

View File

@ -17,6 +17,7 @@ import {TypeChecker} from '../diagnostics/check_types';
import {CompilerHost, CompilerOptions, DiagnosticCategory} from './api';
import {Diagnostic, EmitFlags, Program} from './api';
import {LowerMetadataCache, getExpressionLoweringTransformFactory} from './lower_expressions';
import {getAngularEmitterTransformFactory} from './node_emitter_transform';
const GENERATED_FILES = /\.ngfactory\.js$|\.ngstyle\.js$|\.ngsummary\.js$/;
@ -35,7 +36,7 @@ class AngularCompilerProgram implements Program {
private aotCompilerHost: AotCompilerHost;
private compiler: AotCompiler;
private srcNames: string[];
private collector: MetadataCollector;
private metadataCache: LowerMetadataCache;
// Lazily initialized fields
private _analyzedModules: NgAnalyzedModules|undefined;
private _structuralDiagnostics: Diagnostic[] = [];
@ -55,13 +56,14 @@ class AngularCompilerProgram implements Program {
this.tsProgram = ts.createProgram(rootNames, options, host, this.oldTsProgram);
this.srcNames = this.tsProgram.getSourceFiles().map(sf => sf.fileName);
this.aotCompilerHost = new AotCompilerHost(this.tsProgram, options, host);
this.metadataCache = new LowerMetadataCache({quotedNames: true}, !!options.strictMetadataEmit);
this.aotCompilerHost = new AotCompilerHost(
this.tsProgram, options, host, /* collectorOptions */ undefined, this.metadataCache);
if (host.readResource) {
this.aotCompilerHost.loadResource = host.readResource.bind(host);
}
const {compiler} = createAotCompiler(this.aotCompilerHost, options);
this.compiler = compiler;
this.collector = new MetadataCollector({quotedNames: true});
}
// Program implementation
@ -118,11 +120,9 @@ class AngularCompilerProgram implements Program {
const emitMap = new Map<string, string>();
const result = this.programWithStubs.emit(
/* targetSourceFile */ undefined,
createWriteFileCallback(emitFlags, this.host, this.collector, this.options, emitMap),
cancellationToken, (emitFlags & (EmitFlags.DTS | EmitFlags.JS)) == EmitFlags.DTS, {
after: this.options.skipTemplateCodegen ? [] : [getAngularEmitterTransformFactory(
this.generatedFiles)]
});
createWriteFileCallback(emitFlags, this.host, this.metadataCache, emitMap),
cancellationToken, (emitFlags & (EmitFlags.DTS | EmitFlags.JS)) == EmitFlags.DTS,
this.calculateTransforms());
this.generatedFiles.forEach(file => {
if (file.source && file.source.length && SUMMARY_JSON_FILES.test(file.genFileUrl)) {
@ -184,6 +184,21 @@ class AngularCompilerProgram implements Program {
return this.generatedFiles && this._generatedFileDiagnostics !;
}
private calculateTransforms(): ts.CustomTransformers {
const before: ts.TransformerFactory<ts.SourceFile>[] = [];
const after: ts.TransformerFactory<ts.SourceFile>[] = [];
if (!this.options.disableExpressionLowering) {
before.push(getExpressionLoweringTransformFactory(this.metadataCache));
}
if (!this.options.skipTemplateCodegen) {
after.push(getAngularEmitterTransformFactory(this.generatedFiles));
}
const result: ts.CustomTransformers = {};
if (before.length) result.before = before;
if (after.length) result.after = after;
return result;
}
private catchAnalysisError(e: any): NgAnalyzedModules {
if (isSyntaxError(e)) {
const parserErrors = getParseErrors(e);
@ -257,8 +272,7 @@ export function createProgram(
}
function writeMetadata(
emitFilePath: string, sourceFile: ts.SourceFile, collector: MetadataCollector,
ngOptions: CompilerOptions) {
emitFilePath: string, sourceFile: ts.SourceFile, metadataCache: LowerMetadataCache) {
if (/\.js$/.test(emitFilePath)) {
const path = emitFilePath.replace(/\.js$/, '.metadata.json');
@ -271,7 +285,7 @@ function writeMetadata(
collectableFile = (collectableFile as any).original;
}
const metadata = collector.getMetadata(collectableFile, !!ngOptions.strictMetadataEmit);
const metadata = metadataCache.getMetadata(collectableFile);
if (metadata) {
const metadataText = JSON.stringify([metadata]);
writeFileSync(path, metadataText, {encoding: 'utf-8'});
@ -280,8 +294,8 @@ function writeMetadata(
}
function createWriteFileCallback(
emitFlags: EmitFlags, host: ts.CompilerHost, collector: MetadataCollector,
ngOptions: CompilerOptions, emitMap: Map<string, string>) {
emitFlags: EmitFlags, host: ts.CompilerHost, metadataCache: LowerMetadataCache,
emitMap: Map<string, string>) {
const withMetadata =
(fileName: string, data: string, writeByteOrderMark: boolean,
onError?: (message: string) => void, sourceFiles?: ts.SourceFile[]) => {
@ -291,7 +305,7 @@ function createWriteFileCallback(
}
if (!generatedFile && sourceFiles && sourceFiles.length == 1) {
emitMap.set(sourceFiles[0].fileName, fileName);
writeMetadata(fileName, sourceFiles[0], collector, ngOptions);
writeMetadata(fileName, sourceFiles[0], metadataCache);
}
};
const withoutMetadata =

View File

@ -308,7 +308,6 @@ describe('ngc command-line', () => {
const exitCode = main(['-p', path.join(basePath, 'tsconfig.json')]);
expect(exitCode).toEqual(0);
expect(fs.existsSync(path.resolve(outDir, 'mymodule.ngfactory.js'))).toBe(true);
expect(fs.existsSync(path.resolve(
outDir, 'node_modules', '@angular', 'core', 'src',
@ -316,6 +315,123 @@ describe('ngc command-line', () => {
.toBe(true);
});
describe('expression lowering', () => {
beforeEach(() => {
writeConfig(`{
"extends": "./tsconfig-base.json",
"files": ["mymodule.ts"]
}`);
});
function compile(): number {
const errors: string[] = [];
const result = main(['-p', path.join(basePath, 'tsconfig.json')], s => errors.push(s));
expect(errors).toEqual([]);
return result;
}
it('should be able to lower a lambda expression in a provider', () => {
write('mymodule.ts', `
import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';
class Foo {}
@NgModule({
imports: [CommonModule],
providers: [{provide: 'someToken', useFactory: () => new Foo()}]
})
export class MyModule {}
`);
expect(compile()).toEqual(0);
const mymodulejs = path.resolve(outDir, 'mymodule.js');
const mymoduleSource = fs.readFileSync(mymodulejs, 'utf8');
expect(mymoduleSource).toContain('var ɵ0 = function () { return new Foo(); }');
expect(mymoduleSource).toContain('export { ɵ0');
const mymodulefactory = path.resolve(outDir, 'mymodule.ngfactory.js');
const mymodulefactorySource = fs.readFileSync(mymodulefactory, 'utf8');
expect(mymodulefactorySource).toContain('"someToken", i1.ɵ0');
});
it('should be able to lower a function expression in a provider', () => {
write('mymodule.ts', `
import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';
class Foo {}
@NgModule({
imports: [CommonModule],
providers: [{provide: 'someToken', useFactory: function() {return new Foo();}}]
})
export class MyModule {}
`);
expect(compile()).toEqual(0);
const mymodulejs = path.resolve(outDir, 'mymodule.js');
const mymoduleSource = fs.readFileSync(mymodulejs, 'utf8');
expect(mymoduleSource).toContain('var ɵ0 = function () { return new Foo(); }');
expect(mymoduleSource).toContain('export { ɵ0');
const mymodulefactory = path.resolve(outDir, 'mymodule.ngfactory.js');
const mymodulefactorySource = fs.readFileSync(mymodulefactory, 'utf8');
expect(mymodulefactorySource).toContain('"someToken", i1.ɵ0');
});
it('should able to lower multiple expressions', () => {
write('mymodule.ts', `
import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';
class Foo {}
@NgModule({
imports: [CommonModule],
providers: [
{provide: 'someToken', useFactory: () => new Foo()},
{provide: 'someToken', useFactory: () => new Foo()},
{provide: 'someToken', useFactory: () => new Foo()},
{provide: 'someToken', useFactory: () => new Foo()}
]
})
export class MyModule {}
`);
expect(compile()).toEqual(0);
const mymodulejs = path.resolve(outDir, 'mymodule.js');
const mymoduleSource = fs.readFileSync(mymodulejs, 'utf8');
expect(mymoduleSource).toContain('ɵ0 = function () { return new Foo(); }');
expect(mymoduleSource).toContain('ɵ1 = function () { return new Foo(); }');
expect(mymoduleSource).toContain('ɵ2 = function () { return new Foo(); }');
expect(mymoduleSource).toContain('ɵ3 = function () { return new Foo(); }');
expect(mymoduleSource).toContain('export { ɵ0, ɵ1, ɵ2, ɵ3');
});
it('should be able to lower an indirect expression', () => {
write('mymodule.ts', `
import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';
class Foo {}
const factory = () => new Foo();
@NgModule({
imports: [CommonModule],
providers: [{provide: 'someToken', useFactory: factory}]
})
export class MyModule {}
`);
expect(compile()).toEqual(0);
const mymodulejs = path.resolve(outDir, 'mymodule.js');
const mymoduleSource = fs.readFileSync(mymodulejs, 'utf8');
expect(mymoduleSource).toContain('var ɵ0 = function () { return new Foo(); }');
expect(mymoduleSource).toContain('export { ɵ0');
});
});
const shouldExist = (fileName: string) => {
if (!fs.existsSync(path.resolve(outDir, fileName))) {
throw new Error(`Expected ${fileName} to be emitted (outDir: ${outDir})`);

View File

@ -0,0 +1,107 @@
/**
* @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 {LoweringRequest, RequestLocationMap, getExpressionLoweringTransformFactory} from '../../src/transformers/lower_expressions';
import {Directory, MockAotContext, MockCompilerHost} from '../mocks';
describe('Expression lowering', () => {
it('should be able to lower a simple expression', () => {
expect(convert('const a = 1 +◊b: 2◊;')).toBe('const b = 2; const a = 1 + b; export { b };');
});
it('should be able to lower an expression in a decorator', () => {
expect(convert(`
import {Component} from '@angular/core';
@Component({
provider: [{provide: 'someToken', useFactory:l: () => null}]
})
class MyClass {}
`)).toContain('const l = () => null; exports.l = l;');
});
});
function convert(annotatedSource: string) {
const annotations: {start: number, length: number, name: string}[] = [];
let adjustment = 0;
const unannotatedSource = annotatedSource.replace(
/◊([a-zA-Z]+):(.*)◊/g,
(text: string, name: string, source: string, index: number): string => {
annotations.push({start: index + adjustment, length: source.length, name});
adjustment -= text.length - source.length;
return source;
});
const baseFileName = 'someFile';
const moduleName = '/' + baseFileName;
const fileName = moduleName + '.ts';
const context = new MockAotContext('/', {[baseFileName + '.ts']: unannotatedSource});
const host = new MockCompilerHost(context);
const sourceFile = ts.createSourceFile(
fileName, unannotatedSource, ts.ScriptTarget.Latest, /* setParentNodes */ true);
const requests = new Map<number, LoweringRequest>();
for (const annotation of annotations) {
const node = findNode(sourceFile, annotation.start, annotation.length);
expect(node).toBeDefined();
if (node) {
const location = node.pos;
requests.set(location, {name: annotation.name, kind: node.kind, location, end: node.end});
}
}
const program = ts.createProgram(
[fileName], {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017}, host);
const moduleSourceFile = program.getSourceFile(fileName);
const transformers: ts.CustomTransformers = {
before: [getExpressionLoweringTransformFactory({
getRequests(sourceFile: ts.SourceFile): RequestLocationMap{
if (sourceFile.fileName == moduleSourceFile.fileName) {
return requests;
} else {return new Map();}
}
})]
};
let result: string = '';
const emitResult = program.emit(
moduleSourceFile, (emittedFileName, data, writeByteOrderMark, onError, sourceFiles) => {
if (fileName.startsWith(moduleName)) {
result = data;
}
}, undefined, undefined, transformers);
return normalizeResult(result);
};
function findNode(node: ts.Node, start: number, length: number): ts.Node|undefined {
function find(node: ts.Node): ts.Node|undefined {
if (node.getFullStart() == start && node.getEnd() == start + length) {
return node;
}
if (node.getFullStart() <= start && node.getEnd() >= start + length) {
return ts.forEachChild(node, find);
}
}
return ts.forEachChild(node, find);
}
function normalizeResult(result: string): string {
// Remove TypeScript prefixes
// Remove new lines
// Squish adjacent spaces
// Remove prefix and postfix spaces
return result.replace('"use strict";', ' ')
.replace('exports.__esModule = true;', ' ')
.replace('Object.defineProperty(exports, "__esModule", { value: true });', ' ')
.replace(/\n/g, ' ')
.replace(/ +/g, ' ')
.replace(/^ /g, '')
.replace(/ $/g, '');
}