High-perf source map builder

Summary:
Adds a high performance source map builder that has certain restrictions compared to the `'source-map'` package:

- mappings have to be in the order of the generated source
- source files have to be started/ended separately on the generator. That means building up mappings is optimized for blocks of mappings that all belong to the same source file (or no file)

The implementation avoids allocation of complex value, i.e. strings and objects as much as possible by preallocating a buffer and using numeric character values throughout. The buffer is converted to a string only at the end.

This implementation is ~5✕ faster than using `'source-map'`.

Reviewed By: jeanlauliac

Differential Revision: D4392260

fbshipit-source-id: 406381302d951b919243a2b15e8bb75981e9f979
This commit is contained in:
David Aurelio 2017-01-09 11:30:36 -08:00 committed by Facebook Github Bot
parent 5cdfc42c71
commit b9307f21eb
5 changed files with 487 additions and 0 deletions

View File

@ -0,0 +1,105 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @flow
*/
'use strict';
const encode = require('./encode');
const MAX_SEGMENT_LENGTH = 7;
const ONE_MEG = 1024 * 1024;
const COMMA = 0x2c;
const SEMICOLON = 0x3b;
/**
* Efficient builder for base64 VLQ mappings strings.
*
* This class uses a buffer that is preallocated with one megabyte and is
* reallocated dynamically as needed, doubling its size.
*
* Encoding never creates any complex value types (strings, objects), and only
* writes character values to the buffer.
*
* For details about source map terminology and specification, check
* https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit
*/
class B64Builder {
buffer: Buffer;
pos: number;
hasSegment: boolean;
constructor() {
this.buffer = new Buffer(ONE_MEG);
this.pos = 0;
this.hasSegment = false;
}
/**
* Adds `n` markers for generated lines to the mappings.
*/
markLines(n: number) {
this.hasSegment = false;
if (this.pos + n >= this.buffer.length) {
this._realloc();
}
while (n--) {
this.buffer[this.pos++] = SEMICOLON;
}
return this;
}
/**
* Starts a segment at the specified column offset in the current line.
*/
startSegment(column: number) {
if (this.hasSegment) {
this._writeByte(COMMA);
} else {
this.hasSegment = true;
}
this.append(column);
return this;
}
/**
* Appends a single number to the mappings.
*/
append(value: number) {
if (this.pos + MAX_SEGMENT_LENGTH >= this.buffer.length) {
this._realloc();
}
this.pos = encode(value, this.buffer, this.pos);
return this;
}
/**
* Returns the string representation of the mappings.
*/
toString() {
return this.buffer.toString('ascii', 0, this.pos);
}
_writeByte(byte) {
if (this.pos === this.buffer.length) {
this._realloc();
}
this.buffer[this.pos++] = byte;
}
_realloc() {
const {buffer} = this;
this.buffer = new Buffer(buffer.length * 2);
buffer.copy(this.buffer);
}
}
module.exports = B64Builder;

View File

@ -0,0 +1,188 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @flow
*/
'use strict';
const B64Builder = require('./B64Builder');
import type {SourceMap} from 'babel-core';
/**
* Generates a source map from raw mappings.
*
* Raw mappings are a set of 2, 4, or five elements:
*
* - line and column number in the generated source
* - line and column number in the original source
* - symbol name in the original source
*
* Mappings have to be passed in the order appearance in the generated source.
*/
class Generator {
builder: B64Builder;
last: {|
generatedColumn: number,
generatedLine: number,
name: number,
source: number,
sourceColumn: number,
sourceLine: number,
|};
names: IndexedSet;
source: number;
sources: Array<string>;
sourcesContent: Array<string>;
constructor() {
this.builder = new B64Builder();
this.last = {
generatedColumn: 0,
generatedLine: 1, // lines are passed in 1-indexed
name: 0,
source: 0,
sourceColumn: 0,
sourceLine: 1,
};
this.names = new IndexedSet();
this.source = -1;
this.sources = [];
this.sourcesContent = [];
}
/**
* Mark the beginning of a new source file.
*/
startFile(file: string, code: string) {
this.source = this.sources.push(file) - 1;
this.sourcesContent.push(code);
}
/**
* Mark the end of the current source file
*/
endFile() {
this.source = -1;
}
/**
* Add a mapping that contains the first 2, 4, or all of the following values:
*
* 1. line offset in the generated source
* 2. column offset in the generated source
* 3. line offset in the original source
* 4. column offset in the original source
* 5. name of the symbol in the original source.
*/
addMapping(
generatedLine: number,
generatedColumn: number,
sourceLine?: number,
sourceColumn?: number,
name?: string,
): void {
var {last} = this;
if (this.source === -1 ||
generatedLine === last.generatedLine &&
generatedColumn < last.generatedColumn ||
generatedLine < last.generatedLine) {
const msg = this.source === -1
? 'Cannot add mapping before starting a file with `addFile()`'
: 'Mapping is for a position preceding an earlier mapping';
throw new Error(msg);
}
if (generatedLine > last.generatedLine) {
this.builder.markLines(generatedLine - last.generatedLine);
last.generatedLine = generatedLine;
last.generatedColumn = 0;
}
this.builder.startSegment(generatedColumn - last.generatedColumn);
last.generatedColumn = generatedColumn;
if (sourceLine != null) {
if (sourceColumn == null) {
throw new Error(
'Received mapping with source line, but without source column');
}
this.builder
.append(this.source - last.source)
.append(sourceLine - last.sourceLine)
.append(sourceColumn - last.sourceColumn);
last.source = this.source;
last.sourceColumn = sourceColumn;
last.sourceLine = sourceLine;
if (name != null) {
const nameIndex = this.names.indexFor(name);
this.builder.append(nameIndex - last.name);
last.name = nameIndex;
}
}
}
/**
* Return the source map as object.
*/
toMap(file?: string): SourceMap {
return {
version: 3,
file,
sources: this.sources.slice(),
sourcesContent: this.sourcesContent.slice(),
names: this.names.items(),
mappings: this.builder.toString(),
};
}
/**
* Return the source map as string.
*
* This is ~2.5x faster than calling `JSON.stringify(generator.toMap())`
*/
toString(file?: string): string {
return ('{' +
'"version":3,' +
(file ? `"file":${JSON.stringify(file)},` : '') +
`"sources":${JSON.stringify(this.sources)},` +
`"sourcesContent":${JSON.stringify(this.sourcesContent)},` +
`"names":${JSON.stringify(this.names.items())},` +
`"mappings":"${this.builder.toString()}"` +
'}');
}
}
class IndexedSet {
map: Map<string, number>;
nextIndex: number;
constructor() {
this.map = new Map();
this.nextIndex = 0;
}
indexFor(x: string) {
let index = this.map.get(x);
if (index == null) {
index = this.nextIndex++;
this.map.set(x, index);
}
return index;
}
items() {
return Array.from(this.map.keys());
}
}
module.exports = Generator;

View File

@ -0,0 +1,125 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @flow
*/
/**
* Copyright 2011 Mozilla Foundation and contributors
* Licensed under the New BSD license. See LICENSE or:
* http://opensource.org/licenses/BSD-3-Clause
*
* Based on the Base 64 VLQ implementation in Closure Compiler:
* https://code.google.com/p/closure-compiler/source/browse/trunk/src/com/google/debugging/sourcemap/Base64VLQ.java
*
* Copyright 2011 The Closure Compiler Authors. All rights reserved.
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following
* disclaimer in the documentation and/or other materials provided
* with the distribution.
* * Neither the name of Google Inc. nor the names of its
* contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* @copyright
*/
/* eslint-disable no-bitwise */
'use strict';
// A map of values to characters for the b64 encoding
const CHAR_MAP = [
0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48,
0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50,
0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58,
0x59, 0x5a, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e,
0x6f, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76,
0x77, 0x78, 0x79, 0x7a, 0x30, 0x31, 0x32, 0x33,
0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x2b, 0x2f,
];
// A single base 64 digit can contain 6 bits of data. For the base 64 variable
// length quantities we use in the source map spec, the first bit is the sign,
// the next four bits are the actual value, and the 6th bit is the
// continuation bit. The continuation bit tells us whether there are more
// digits in this value following this digit.
//
// Continuation
// | Sign
// | |
// V V
// 101011
const VLQ_BASE_SHIFT = 5;
// binary: 100000
const VLQ_BASE = 1 << VLQ_BASE_SHIFT;
// binary: 011111
const VLQ_BASE_MASK = VLQ_BASE - 1;
// binary: 100000
const VLQ_CONTINUATION_BIT = VLQ_BASE;
/**
* Converts from a two-complement value to a value where the sign bit is
* placed in the least significant bit. For example, as decimals:
* 1 becomes 2 (10 binary), -1 becomes 3 (11 binary)
* 2 becomes 4 (100 binary), -2 becomes 5 (101 binary)
*/
function toVLQSigned(value) {
return value < 0
? ((-value) << 1) + 1
: (value << 1) + 0;
}
/**
* Encodes a number to base64 VLQ format and appends it to the passed-in buffer
*
* DON'T USE COMPOUND OPERATORS (eg `>>>=`) ON `let`-DECLARED VARIABLES!
* V8 WILL DEOPTIMIZE THIS FUNCTION AND MAP CREATION WILL BE 25% SLOWER!
*
* DON'T ADD MORE COMMENTS TO THIS FUNCTION TO KEEP ITS LENGTH SHORT ENOUGH FOR
* V8 OPTIMIZATION!
*/
function encode(value: number, buffer: Buffer, position: number): number {
let digit, vlq = toVLQSigned(value);
do {
digit = vlq & VLQ_BASE_MASK;
vlq = vlq >>> VLQ_BASE_SHIFT;
if (vlq > 0) {
// There are still more digits in this value, so we must make sure the
// continuation bit is marked.
digit = digit | VLQ_CONTINUATION_BIT;
}
buffer[position++] = CHAR_MAP[digit];
} while (vlq > 0);
return position;
}
module.exports = encode;

View File

@ -0,0 +1 @@
{"main": "source-map.js"}

View File

@ -0,0 +1,68 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @flow
*/
'use strict';
const Generator = require('./Generator');
import type ModuleTransport from '../../lib/ModuleTransport';
/**
* Creates a source map from modules with "raw mappings", i.e. an array of
* tuples with either 2, 4, or 5 elements:
* generated line, generated column, source line, source line, symbol name.
*/
function fromRawMappings(modules: Array<ModuleTransport>): string {
const generator = new Generator();
let carryOver = 0;
for (var j = 0, o = modules.length; j < o; ++j) {
var module = modules[j];
var {code, map} = module;
if (Array.isArray(map)) {
addMappingsForFile(generator, map, module, carryOver);
} else if (map != null) {
throw new Error(
`Unexpected module with full source map found: ${module.sourcePath}`
);
}
carryOver = carryOver + countLines(code);
}
return generator.toString();
}
function addMappingsForFile(generator, mappings, module, carryOver) {
generator.startFile(module.sourcePath, module.sourceCode);
const columnOffset = module.code.indexOf('{') + 1;
for (let i = 0, n = mappings.length; i < n; ++i) {
const mapping = mappings[i];
generator.addMapping(
mapping[0] + carryOver,
// lines start at 1, columns start at 0
mapping[0] === 1 && mapping[1] + columnOffset || mapping[1],
mapping[2],
mapping[3],
mapping[4],
);
}
generator.endFile();
}
function countLines(string) {
return string.split('\n').length;
}
exports.fromRawMappings = fromRawMappings;