Skip to content

Commit

Permalink
feat(ngcc): source map flattening
Browse files Browse the repository at this point in the history
  • Loading branch information
petebacondarwin committed Feb 3, 2020
1 parent 4d36b2f commit 90969cf
Show file tree
Hide file tree
Showing 8 changed files with 852 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/compiler-cli/ngcc/BUILD.bazel
Expand Up @@ -35,6 +35,7 @@ ts_library(
"@npm//magic-string",
"@npm//semver",
"@npm//source-map",
"@npm//sourcemap-codec",
"@npm//typescript",
],
)
33 changes: 33 additions & 0 deletions packages/compiler-cli/ngcc/src/sourcemaps/cycle_tracker.ts
@@ -0,0 +1,33 @@
/**
* @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
*/

/**
* A utility class to track cycles when running a potentially recursive function.
*/
export class CycleTracker<T> {
private items: T[] = [];

/**
* Check that the `item` has not been tracked already; and run the `callback`.
*
* Use this function to wrap a potentially recursive function that needs to ensure that
* it does not get stuck in an infinite recursion due to a cycle.
*/
track<K>(item: T, callback: () => K): K {
try {
if (this.items.includes(item)) {
this.items.push(item);
throw new Error(`Illegal cycle found: ${this.items.join(' -> ')}`);
}
this.items.push(item);
return callback();
} finally {
this.items.pop();
}
}
}
21 changes: 21 additions & 0 deletions packages/compiler-cli/ngcc/src/sourcemaps/raw_source_map.ts
@@ -0,0 +1,21 @@
/**
* @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
*/

/**
* This interface is the basic structure of the JSON in a raw source map that one might load from
* disk.
*/
export interface RawSourceMap {
version: number|string;
file?: string;
sourceRoot?: string;
sources: string[];
names: string[];
sourcesContent?: (string|null)[];
mappings: string;
}
309 changes: 309 additions & 0 deletions packages/compiler-cli/ngcc/src/sourcemaps/source_file.ts
@@ -0,0 +1,309 @@
/**
* @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 {SourceMapMappings, SourceMapSegment, decode, encode} from 'sourcemap-codec';
import {AbsoluteFsPath, dirname, relative} from '../../../src/ngtsc/file_system';
import {RawSourceMap} from './raw_source_map';

export class SourceFile {
/**
* The mappings parsed from the source file's source map.
* If the file has no source map then mappings will be an empty array.
*/
readonly mappings: Mapping[];

/**
* A array original segments for each of the parsed `mappings`.
* These are used to compute the ends of incoming segments.
*/
readonly originalSegments: SegmentMarker[];

/**
* The parsed mappings that have been flattened so that and intermediate source mappings have been
* flattened.
*
* The result is that any source file mentioned in the flattened mappings have no source map (are
* pure original source files).
*/
readonly flattenedMappings: Mapping[];

constructor(
/** The path to this source file. */
readonly sourcePath: AbsoluteFsPath,
/** The contents of this source file. */
readonly contents: string,
/** The raw source map (if any) associated with this source file. */
readonly rawMap: RawSourceMap|null,
/** Any source files referenced by the raw source map associated with this source file. */
readonly sources: (SourceFile|null)[]) {
this.mappings = this.parseMappings();
this.originalSegments = this.mappings.map(mapping => mapping.originalSegment);
this.originalSegments.sort(compareSegments);
this.flattenedMappings = this.flattenMappings();
}

/**
* Render the raw source map generated from the flattened mappings.
*/
renderFlattenedSourceMap(): RawSourceMap {
const sources: SourceFile[] = [];
const names: string[] = [];
const mappings: SourceMapMappings = [];
for (const mapping of this.flattenedMappings) {
if (mappings[mapping.generatedSegment.line] === undefined) {
mappings[mapping.generatedSegment.line] = [];
}
const mappingLine = mappings[mapping.generatedSegment.line];
const sourceIndex = findIndexOrAdd(sources, mapping.originalSegment.sourceFile);
const mappingArray: SourceMapSegment = [
mapping.generatedSegment.column,
sourceIndex,
mapping.originalSegment.line,
mapping.originalSegment.column,
];
if (mapping.name !== undefined) {
const nameIndex = findIndexOrAdd(names, mapping.name);
mappingArray.push(nameIndex);
}
mappingLine.push(mappingArray);
}
const sourcePathDir = dirname(this.sourcePath);
const sourceMap: RawSourceMap = {
version: 3,
sources: sources.map(sf => relative(sourcePathDir, sf.sourcePath)), names,
mappings: encode(mappings),
sourcesContent: sources.map(sf => sf.contents),
};
return sourceMap;
}

/**
* Parse the raw mappings into a collection of parsed mappings (stored in the `mappings`) and a
* tree of `SourceFile`s (stored in the `sources` property).
*/
private parseMappings(): Mapping[] {
const mappings: Mapping[] = [];
if (this.rawMap !== null) {
const rawMappings = decode(this.rawMap.mappings);
if (rawMappings !== null) {
for (let generatedLine = 0; generatedLine < rawMappings.length; generatedLine++) {
const generatedLineMappings = rawMappings[generatedLine];
for (const rawMapping of generatedLineMappings) {
const generatedColumn = rawMapping[0];
const generatedSegment = {
sourceFile: this,
line: generatedLine,
column: generatedColumn
};
if (rawMapping.length >= 4) {
const originalSegment = {
sourceFile: this.sources[rawMapping[1] !] !,
line: rawMapping[2] !,
column: rawMapping[3] !
};
const name = rawMapping.length === 5 ? this.rawMap.names[rawMapping[4]] : undefined;
const mapping = {generatedSegment, originalSegment, name};
mappings.push(mapping);
}
}
}
}
}
return mappings;
}

/**
* Flatten the parsed mappings for this source file, so that all the mappings are to pure original
* source files with no transitive source maps.
*
* TODO: expand of the algorithm...
*/
private flattenMappings(): Mapping[] {
const flattenedMappings: Mapping[] = [];
debugger;
for (let mappingIndex = 0; mappingIndex < this.mappings.length; mappingIndex++) {
const aToBmapping = this.mappings[mappingIndex];
const bSource = aToBmapping.originalSegment.sourceFile;
if (bSource.flattenedMappings.length === 0) {
// The b source file has no mappings of its own (i.e. it a pure original file)
// so just use the mapping as-is.
flattenedMappings.push(aToBmapping);
continue;
}

const incomingStart = aToBmapping.originalSegment;
const lowerBoundOfStartIndex =
findIndexOfLowerBound(bSource.flattenedMappings, incomingStart);

const incomingEnd = this.originalSegments[this.originalSegments.indexOf(incomingStart) + 1];
const lowerBoundOfEndIndex =
findIndexOfLowerBound(bSource.flattenedMappings, incomingEnd, lowerBoundOfStartIndex);

for (let bToCmappingIndex = lowerBoundOfStartIndex; bToCmappingIndex <= lowerBoundOfEndIndex;
bToCmappingIndex++) {
const bToCmapping = bSource.flattenedMappings[bToCmappingIndex];
if (bToCmapping) {
flattenedMappings.push(mergeMappings(aToBmapping, bToCmapping));
}
}
}
return flattenedMappings;
}
}

/**
* A Mapping consists of two segment markers one in the generated source and one in the original
* source, which indicate the start of each segment. The end of a segment is indicated by the
* the first segment marker of another mapping whose start is greater or equal to this one.
*
* It may also include a name associated with the segment being mapped.
*/
export interface Mapping {
readonly generatedSegment: SegmentMarker;
readonly originalSegment: SegmentMarker;
readonly name?: string;
}

/**
* A marker that indicates the start of a segment in a mapping.
*
* The end of a segment is indicated by the the first segment-marker of another mapping whose start
* is greater or equal to this one.
*/
export interface SegmentMarker {
readonly sourceFile: SourceFile;
readonly line: number;
readonly column: number;
}

/**
* Compare two segment-markers, for use in a search or sorting algorithm.
*
* @returns a positive number of `a` is after `b`, a negative number if `b` is after `a`
* and zero if they are at the same position.
*/
function compareSegments(a: SegmentMarker, b: SegmentMarker | undefined): number {
return b === undefined ? -1 : a.line === b.line ? a.column - b.column : a.line - b.line;
}

/**
* Find the index of the lower bound mapping for `marker.
* The lower bound is the mapping whose generated segment starts before the given segment `marker`.
*
* @param mappings The collection of mappings to search for the lower bound mapping.
* @param marker The segment-marker whose lower bound we are searching for.
* @param startIndexHint If we know that the mapping must start after a certain index then this can
* be provided here to improve the search performance.
* @returns The index of the lower bound mapping or -1 if not found.
*/
function findIndexOfLowerBound(
mappings: Mapping[], marker: SegmentMarker | undefined, startIndexHint: number = -1): number {
if (startIndexHint === -1) {
startIndexHint = 0;
}
for (let index = startIndexHint; index < mappings.length; index++) {
if (compareSegments(mappings[index].generatedSegment, marker) >= 0) {
return index - 1;
}
}
return -1;
}

/**
* Find `item` in `items` or, if it is not found, push it to the end of the array.
* @param item the item to look for.
* @param items the collection in which to look for `item`.
* @returns the index of the `item` in `items`.
*/
function findIndexOrAdd<T>(items: T[], item: T): number {
const itemIndex = items.indexOf(item);
if (itemIndex > -1) {
return itemIndex;
} else {
items.push(item);
return items.length - 1;
}
}


/**
* Merge two mappings that go from A to B and B to C, to result in a mapping that goes from A to C.
*/
export function mergeMappings(ab: Mapping, bc: Mapping): Mapping {
const name = bc.name || ab.name;

// We need to modify the segment-markers of the new mapping to take into account the shifts that
// occur due to the combination of the two mappings.
// For example:

// * Simple map where the B->C starts at the same place the A->B ends:
//
// ```
// A: 1 2 b c d
// | A->B [2,0]
// | |
// B: b c d A->C [2,1]
// | |
// | B->C [0,1]
// C: a b c d e
// ```

// * More complicated case where diffs of segment-markers is needed:
//
// ```
// A: b 1 2 c d
// \
// | A->B [0,1*] [0,1*]
// | | |+3
// B: a b 1 2 c d A->C [0,1] [3,2]
// | / |+1 |
// | / B->C [0*,0] [4*,2]
// | /
// C: a b c d e
// ```
//
// `[0,1]` mapping from A->C:
// The difference between the "original segment-marker" of A->B (1*) and the "generated
// segment-marker of B->C (0*): `1 - 0 = +1`.
// Since it is positive we must increment the "original segment-marker" with `1` to give [0,1].
//
// `[3,2]` mapping from A->C:
// The difference between the "original segment-marker" of A->B (1*) and the "generated
// segment-marker" of B->C (4*): `1 - 4 = -3`.
// Since it is negative we must increment the "generated segment-marker" with `3` to give [3,2].

const diff = segmentDiff(ab.originalSegment, bc.generatedSegment);
if (diff.negative) {
return {
name,
generatedSegment: {
sourceFile: ab.generatedSegment.sourceFile,
line: ab.generatedSegment.line - diff.line,
column: ab.generatedSegment.column - diff.column
},
originalSegment: bc.originalSegment,
};
} else {
return {
name, generatedSegment: ab.generatedSegment, originalSegment: {
sourceFile: bc.originalSegment.sourceFile,
line: bc.originalSegment.line + diff.line,
column: bc.originalSegment.column + diff.column
},
}
}
}

/**
* Compute the difference between two segments
*/
function segmentDiff(a: SegmentMarker, b: SegmentMarker) {
const line = a.line - b.line;
const column = a.column - b.column;
const negative = line !== 0 ? line < 0 : column < 0;
return {line, column, negative};
}

0 comments on commit 90969cf

Please sign in to comment.