feat(compiler): add support for source map generation (#14258)

fixes #14125

PR Close #14258
This commit is contained in:
Victor Berchet
2017-01-27 14:23:12 -08:00
committed by Miško Hevery
parent 53cf2ec573
commit 7ac38aa357
20 changed files with 1061 additions and 381 deletions

View File

@ -0,0 +1,148 @@
/**
* @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 {ParseLocation, ParseSourceFile, ParseSourceSpan} from '@angular/compiler';
import {EmitterVisitorContext} from '@angular/compiler/src/output/abstract_emitter';
import {SourceMap} from '@angular/compiler/src/output/source_map';
const SourceMapConsumer = require('source-map').SourceMapConsumer;
const b64 = require('base64-js');
export function main() {
describe('AbstractEmitter', () => {
describe('EmitterVisitorContext', () => {
const fileA = new ParseSourceFile('a0a1a2a3a4a5a6a7a8a9', 'a.js');
const fileB = new ParseSourceFile('b0b1b2b3b4b5b6b7b8b9', 'b.js');
let ctx: EmitterVisitorContext;
beforeEach(() => { ctx = EmitterVisitorContext.createRoot([]); });
it('should add source files to the source map', () => {
ctx.print(createSourceSpan(fileA, 0), 'o0');
ctx.print(createSourceSpan(fileA, 1), 'o1');
ctx.print(createSourceSpan(fileB, 0), 'o2');
ctx.print(createSourceSpan(fileB, 1), 'o3');
const sm = ctx.toSourceMapGenerator('o.js').toJSON();
expect(sm.sources).toEqual([fileA.url, fileB.url]);
expect(sm.sourcesContent).toEqual([fileA.content, fileB.content]);
});
it('should generate a valid mapping', () => {
ctx.print(createSourceSpan(fileA, 0), 'fileA-0');
ctx.println(createSourceSpan(fileB, 1), 'fileB-1');
ctx.print(createSourceSpan(fileA, 2), 'fileA-2');
expectMap(ctx, 0, 0, 'a.js', 0, 0);
expectMap(ctx, 0, 7, 'b.js', 0, 2);
expectMap(ctx, 1, 0, 'a.js', 0, 4);
});
it('should be able to shift the content', () => {
ctx.print(createSourceSpan(fileA, 0), 'fileA-0');
const sm = ctx.toSourceMapGenerator(null, 10).toJSON();
const smc = new SourceMapConsumer(sm);
expect(smc.originalPositionFor({line: 11, column: 0})).toEqual({
line: 1,
column: 0,
source: 'a.js',
name: null,
});
});
it('should not map leading segment without span', () => {
ctx.print(null, '....');
ctx.print(createSourceSpan(fileA, 0), 'fileA-0');
expectMap(ctx, 0, 0);
expectMap(ctx, 0, 4, 'a.js', 0, 0);
expect(nbSegmentsPerLine(ctx)).toEqual([1]);
});
it('should handle indent', () => {
ctx.incIndent();
ctx.println(createSourceSpan(fileA, 0), 'fileA-0');
ctx.incIndent();
ctx.println(createSourceSpan(fileA, 1), 'fileA-1');
ctx.decIndent();
ctx.println(createSourceSpan(fileA, 2), 'fileA-2');
expectMap(ctx, 0, 0);
expectMap(ctx, 0, 2, 'a.js', 0, 0);
expectMap(ctx, 1, 0);
expectMap(ctx, 1, 2);
expectMap(ctx, 1, 4, 'a.js', 0, 2);
expectMap(ctx, 2, 0);
expectMap(ctx, 2, 2, 'a.js', 0, 4);
expect(nbSegmentsPerLine(ctx)).toEqual([1, 1, 1]);
});
it('should coalesce identical span', () => {
const span = createSourceSpan(fileA, 0);
ctx.print(span, 'fileA-0');
ctx.print(null, '...');
ctx.print(span, 'fileA-0');
ctx.print(createSourceSpan(fileB, 0), 'fileB-0');
expectMap(ctx, 0, 0, 'a.js', 0, 0);
expectMap(ctx, 0, 7, 'a.js', 0, 0);
expectMap(ctx, 0, 10, 'a.js', 0, 0);
expectMap(ctx, 0, 17, 'b.js', 0, 0);
expect(nbSegmentsPerLine(ctx)).toEqual([2]);
});
});
});
}
// All lines / columns indexes are 0-based
// Note: source-map line indexes are 1-based, column 0-based
function expectMap(
ctx: EmitterVisitorContext, genLine: number, genCol: number, source: string = null,
srcLine: number = null, srcCol: number = null) {
const sm = ctx.toSourceMapGenerator().toJSON();
const smc = new SourceMapConsumer(sm);
const genPosition = {line: genLine + 1, column: genCol};
const origPosition = smc.originalPositionFor(genPosition);
expect(origPosition.source).toEqual(source);
expect(origPosition.line).toEqual(srcLine === null ? null : srcLine + 1);
expect(origPosition.column).toEqual(srcCol);
}
// returns the number of segments per line
function nbSegmentsPerLine(ctx: EmitterVisitorContext) {
const sm = ctx.toSourceMapGenerator().toJSON();
const lines = sm.mappings.split(';');
return lines.map(l => {
const m = l.match(/,/g);
return m === null ? 1 : m.length + 1;
});
}
function createSourceSpan(file: ParseSourceFile, idx: number) {
const col = 2 * idx;
const start = new ParseLocation(file, col, 0, col);
const end = new ParseLocation(file, col + 2, 0, col + 2);
const sourceSpan = new ParseSourceSpan(start, end);
return {sourceSpan};
}
export function extractSourceMap(source: string): SourceMap {
let idx = source.lastIndexOf('\n//#');
if (idx == -1) return null;
const smComment = source.slice(idx).trim();
const smB64 = smComment.split('sourceMappingURL=data:application/json;base64,')[1];
return smB64 ? JSON.parse(decodeB64String(smB64)) : null;
}
function decodeB64String(s: string): string {
return b64.toByteArray(s).reduce((s: string, c: number) => s + String.fromCharCode(c), '');
}

View File

@ -7,7 +7,6 @@
*/
import {escapeIdentifier} from '@angular/compiler/src/output/abstract_emitter';
import {describe, expect, it} from '@angular/core/testing/testing_internal';
export function main() {
describe('AbstractEmitter', () => {
@ -31,6 +30,11 @@ export function main() {
it('does not escape class (but it probably should)',
() => { expect(escapeIdentifier('class', false, false)).toEqual('class'); });
});
});
}
export function stripSourceMap(source: string): string {
const smi = source.lastIndexOf('\n//#');
if (smi == -1) return source;
return source.slice(0, smi);
}

View File

@ -0,0 +1,66 @@
/**
* @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 {StaticSymbol} from '@angular/compiler/src/aot/static_symbol';
import {CompileIdentifierMetadata} from '@angular/compiler/src/compile_metadata';
import {JavaScriptEmitter} from '@angular/compiler/src/output/js_emitter';
import * as o from '@angular/compiler/src/output/output_ast';
import {ImportResolver} from '@angular/compiler/src/output/path_util';
import {SourceMap} from '@angular/compiler/src/output/source_map';
import {ParseLocation, ParseSourceFile, ParseSourceSpan} from '@angular/compiler/src/parse_util';
import {extractSourceMap} from './abstract_emitter_node_only_spec';
const SourceMapConsumer = require('source-map').SourceMapConsumer;
const someModuleUrl = 'somePackage/somePath';
class SimpleJsImportGenerator implements ImportResolver {
fileNameToModuleName(importedUrlStr: string, moduleUrlStr: string): string {
return importedUrlStr;
}
getImportAs(symbol: StaticSymbol): StaticSymbol { return null; }
getTypeArity(symbol: StaticSymbol): number /*|null*/ { return null; }
}
export function main() {
describe('JavaScriptEmitter', () => {
let importResolver: ImportResolver;
let emitter: JavaScriptEmitter;
let someVar: o.ReadVarExpr;
beforeEach(() => {
importResolver = new SimpleJsImportGenerator();
emitter = new JavaScriptEmitter(importResolver);
});
function emitSourceMap(
stmt: o.Statement | o.Statement[], exportedVars: string[] = null): SourceMap {
const stmts = Array.isArray(stmt) ? stmt : [stmt];
const source = emitter.emitStatements(someModuleUrl, stmts, exportedVars || []);
return extractSourceMap(source);
}
describe('source maps', () => {
it('should emit an inline source map', () => {
const source = new ParseSourceFile(';;;var', 'in.js');
const startLocation = new ParseLocation(source, 0, 0, 3);
const endLocation = new ParseLocation(source, 7, 0, 6);
const sourceSpan = new ParseSourceSpan(startLocation, endLocation);
const someVar = o.variable('someVar', null, sourceSpan);
const sm = emitSourceMap(someVar.toStmt());
const smc = new SourceMapConsumer(sm);
expect(sm.sources).toEqual(['in.js']);
expect(sm.sourcesContent).toEqual([';;;var']);
expect(smc.originalPositionFor({line: 1, column: 0}))
.toEqual({line: 1, column: 3, source: 'in.js', name: null});
});
});
});
}

View File

@ -12,6 +12,8 @@ import {JavaScriptEmitter} from '@angular/compiler/src/output/js_emitter';
import * as o from '@angular/compiler/src/output/output_ast';
import {ImportResolver} from '@angular/compiler/src/output/path_util';
import {stripSourceMap} from './abstract_emitter_spec';
const someModuleUrl = 'somePackage/somePath';
const anotherModuleUrl = 'somePackage/someOtherPath';
@ -47,10 +49,8 @@ export function main() {
});
function emitStmt(stmt: o.Statement, exportedVars: string[] = null): string {
if (!exportedVars) {
exportedVars = [];
}
return emitter.emitStatements(someModuleUrl, [stmt], exportedVars);
const source = emitter.emitStatements(someModuleUrl, [stmt], exportedVars || []);
return stripSourceMap(source);
}
it('should declare variables', () => {

View File

@ -0,0 +1,130 @@
/**
* @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 {SourceMapGenerator, toBase64String} from '@angular/compiler/src/output/source_map';
export function main() {
describe('source map generation', () => {
describe('generation', () => {
it('should generate a valid source map', () => {
const map = new SourceMapGenerator('out.js')
.addSource('a.js', null)
.addLine()
.addMapping(0, 'a.js', 0, 0)
.addMapping(4, 'a.js', 0, 6)
.addMapping(5, 'a.js', 0, 7)
.addMapping(8, 'a.js', 0, 22)
.addMapping(9, 'a.js', 0, 23)
.addMapping(10, 'a.js', 0, 24)
.addLine()
.addMapping(0, 'a.js', 1, 0)
.addMapping(4, 'a.js', 1, 6)
.addMapping(5, 'a.js', 1, 7)
.addMapping(8, 'a.js', 1, 10)
.addMapping(9, 'a.js', 1, 11)
.addMapping(10, 'a.js', 1, 12)
.addLine()
.addMapping(0, 'a.js', 3, 0)
.addMapping(2, 'a.js', 3, 2)
.addMapping(3, 'a.js', 3, 3)
.addMapping(10, 'a.js', 3, 10)
.addMapping(11, 'a.js', 3, 11)
.addMapping(21, 'a.js', 3, 11)
.addMapping(22, 'a.js', 3, 12)
.addLine()
.addMapping(4, 'a.js', 4, 4)
.addMapping(11, 'a.js', 4, 11)
.addMapping(12, 'a.js', 4, 12)
.addMapping(15, 'a.js', 4, 15)
.addMapping(16, 'a.js', 4, 16)
.addMapping(21, 'a.js', 4, 21)
.addMapping(22, 'a.js', 4, 22)
.addMapping(23, 'a.js', 4, 23)
.addLine()
.addMapping(0, 'a.js', 5, 0)
.addMapping(1, 'a.js', 5, 1)
.addMapping(2, 'a.js', 5, 2)
.addMapping(3, 'a.js', 5, 2);
// Generated with https://sokra.github.io/source-map-visualization using a TS source map
expect(map.toJSON().mappings)
.toEqual(
'AAAA,IAAM,CAAC,GAAe,CAAC,CAAC;AACxB,IAAM,CAAC,GAAG,CAAC,CAAC;AAEZ,EAAE,CAAC,OAAO,CAAC,UAAA,CAAC;IACR,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;AACvB,CAAC,CAAC,CAAA');
});
it('should include the files and their contents', () => {
const map = new SourceMapGenerator('out.js')
.addSource('inline.ts', 'inline')
.addSource('inline.ts', 'inline') // make sur the sources are dedup
.addSource('url.ts', null)
.addLine()
.addMapping(0, 'inline.ts', 0, 0)
.toJSON();
expect(map.file).toEqual('out.js');
expect(map.sources).toEqual(['inline.ts', 'url.ts']);
expect(map.sourcesContent).toEqual(['inline', null]);
});
it('should not generate source maps when there is no mapping', () => {
const smg = new SourceMapGenerator('out.js').addSource('inline.ts', 'inline').addLine();
expect(smg.toJSON()).toEqual(null);
expect(smg.toJsComment()).toEqual('');
});
});
describe('encodeB64String', () => {
it('should return the b64 encoded value', () => {
[['', ''], ['a', 'YQ=='], ['Foo', 'Rm9v'], ['Foo1', 'Rm9vMQ=='], ['Foo12', 'Rm9vMTI='],
['Foo123', 'Rm9vMTIz'],
].forEach(([src, b64]) => expect(toBase64String(src)).toEqual(b64));
});
});
describe('errors', () => {
it('should throw when mappings are added out of order', () => {
expect(() => {
new SourceMapGenerator('out.js')
.addSource('in.js')
.addLine()
.addMapping(10, 'in.js', 0, 0)
.addMapping(0, 'in.js', 0, 0);
}).toThrowError('Mapping should be added in output order');
});
it('should throw when adding segments before any line is created', () => {
expect(() => {
new SourceMapGenerator('out.js').addSource('in.js').addMapping(0, 'in.js', 0, 0);
}).toThrowError('A line must be added before mappings can be added');
});
it('should throw when adding segments referencing unknown sources', () => {
expect(() => {
new SourceMapGenerator('out.js').addSource('in.js').addLine().addMapping(
0, 'in_.js', 0, 0);
}).toThrowError('Unknown source file "in_.js"');
});
it('should throw when adding segments without column', () => {
expect(() => {
new SourceMapGenerator('out.js').addSource('in.js').addLine().addMapping(null);
}).toThrowError('The column in the generated code must be provided');
});
it('should throw when adding segments with a source url but no position', () => {
expect(() => {
new SourceMapGenerator('out.js').addSource('in.js').addLine().addMapping(0, 'in.js');
}).toThrowError('The source location must be provided when a source url is provided');
expect(() => {
new SourceMapGenerator('out.js').addSource('in.js').addLine().addMapping(0, 'in.js', 0);
}).toThrowError('The source location must be provided when a source url is provided');
});
});
});
}

View File

@ -6,74 +6,66 @@
* found in the LICENSE file at https://angular.io/license
*/
import {StaticReflector, StaticSymbol, StaticSymbolCache, StaticSymbolResolver, StaticSymbolResolverHost} from '@angular/compiler';
import {ParseLocation, ParseSourceFile} from '@angular/compiler';
import {StaticSymbol} from '@angular/compiler/src/aot/static_symbol';
import * as o from '@angular/compiler/src/output/output_ast';
import {ImportResolver} from '@angular/compiler/src/output/path_util';
import {SourceMap} from '@angular/compiler/src/output/source_map';
import {TypeScriptEmitter} from '@angular/compiler/src/output/ts_emitter';
import {convertValueToOutputAst} from '@angular/compiler/src/output/value_util';
import {MetadataCollector, isClassMetadata, isMetadataSymbolicCallExpression} from '@angular/tsc-wrapped';
import * as ts from 'typescript';
import {ParseSourceSpan} from '@angular/compiler/src/parse_util';
import {MockSummaryResolver} from '../aot/static_symbol_resolver_spec';
import {extractSourceMap} from './abstract_emitter_node_only_spec';
describe('TypeScriptEmitter (node only)', () => {
it('should quote identifiers quoted in the source', () => {
const sourceText = `
import {Component} from '@angular/core';
const SourceMapConsumer = require('source-map').SourceMapConsumer;
@Component({
providers: [{ provide: 'SomeToken', useValue: {a: 1, 'b': 2, c: 3, 'd': 4}}]
})
export class MyComponent {}
`;
const source = ts.createSourceFile('test.ts', sourceText, ts.ScriptTarget.Latest);
const collector = new MetadataCollector({quotedNames: true});
const stubHost = new StubReflectorHost();
const symbolCache = new StaticSymbolCache();
const symbolResolver =
new StaticSymbolResolver(stubHost, symbolCache, new MockSummaryResolver());
const reflector = new StaticReflector(symbolResolver);
const someModuleUrl = 'somePackage/somePath';
// Get the metadata from the above source
const metadata = collector.getMetadata(source);
const componentMetadata = metadata.metadata['MyComponent'];
// Get the first argument of the decorator call which is passed to @Component
expect(isClassMetadata(componentMetadata)).toBeTruthy();
if (!isClassMetadata(componentMetadata)) return;
const decorators = componentMetadata.decorators;
const firstDecorator = decorators[0];
expect(isMetadataSymbolicCallExpression(firstDecorator)).toBeTruthy();
if (!isMetadataSymbolicCallExpression(firstDecorator)) return;
const firstArgument = firstDecorator.arguments[0];
// Simplify this value using the StaticReflector
const context = reflector.getStaticSymbol('none', 'none');
const argumentValue = reflector.simplify(context, firstArgument);
// Convert the value to an output AST
const outputAst = convertValueToOutputAst(argumentValue);
const statement = outputAst.toStmt();
// Convert the value to text using the typescript emitter
const emitter = new TypeScriptEmitter(new StubImportResolver());
const text = emitter.emitStatements('module', [statement], []);
// Expect the keys for 'b' and 'd' to be quoted but 'a' and 'c' not to be.
expect(text).toContain('\'b\': 2');
expect(text).toContain('\'d\': 4');
expect(text).not.toContain('\'a\'');
expect(text).not.toContain('\'c\'');
});
});
class StubReflectorHost implements StaticSymbolResolverHost {
getMetadataFor(modulePath: string): {[key: string]: any}[] { return []; }
moduleNameToFileName(moduleName: string, containingFile: string): string { return 'somePath'; }
}
class StubImportResolver extends ImportResolver {
fileNameToModuleName(importedFilePath: string, containingFilePath: string): string { return ''; }
class SimpleJsImportGenerator implements ImportResolver {
fileNameToModuleName(importedUrlStr: string, moduleUrlStr: string): string {
return importedUrlStr;
}
getImportAs(symbol: StaticSymbol): StaticSymbol { return null; }
getTypeArity(symbol: StaticSymbol): number /*|null*/ { return null; }
}
export function main() {
// Not supported features of our OutputAst in TS:
// - real `const` like in Dart
// - final fields
describe('TypeScriptEmitter', () => {
let importResolver: ImportResolver;
let emitter: TypeScriptEmitter;
let someVar: o.ReadVarExpr;
beforeEach(() => {
importResolver = new SimpleJsImportGenerator();
emitter = new TypeScriptEmitter(importResolver);
someVar = o.variable('someVar');
});
function emitSourceMap(
stmt: o.Statement | o.Statement[], exportedVars: string[] = null): SourceMap {
const stmts = Array.isArray(stmt) ? stmt : [stmt];
const source = emitter.emitStatements(someModuleUrl, stmts, exportedVars || []);
return extractSourceMap(source);
}
describe('source maps', () => {
it('should emit an inline source map', () => {
const source = new ParseSourceFile(';;;var', 'in.js');
const startLocation = new ParseLocation(source, 0, 0, 3);
const endLocation = new ParseLocation(source, 7, 0, 6);
const sourceSpan = new ParseSourceSpan(startLocation, endLocation);
const someVar = o.variable('someVar', null, sourceSpan);
const sm = emitSourceMap(someVar.toStmt());
const smc = new SourceMapConsumer(sm);
expect(sm.sources).toEqual(['in.js']);
expect(sm.sourcesContent).toEqual([';;;var']);
expect(smc.originalPositionFor({line: 1, column: 0}))
.toEqual({line: 1, column: 3, source: 'in.js', name: null});
});
});
});
}

View File

@ -12,6 +12,8 @@ import * as o from '@angular/compiler/src/output/output_ast';
import {ImportResolver} from '@angular/compiler/src/output/path_util';
import {TypeScriptEmitter} from '@angular/compiler/src/output/ts_emitter';
import {stripSourceMap} from './abstract_emitter_spec';
const someModuleUrl = 'somePackage/somePath';
const anotherModuleUrl = 'somePackage/someOtherPath';
@ -48,11 +50,9 @@ export function main() {
});
function emitStmt(stmt: o.Statement | o.Statement[], exportedVars: string[] = null): string {
if (!exportedVars) {
exportedVars = [];
}
const stmts = Array.isArray(stmt) ? stmt : [stmt];
return emitter.emitStatements(someModuleUrl, stmts, exportedVars);
const source = emitter.emitStatements(someModuleUrl, stmts, exportedVars || []);
return stripSourceMap(source);
}
it('should declare variables', () => {