feat(ngcc): implement source-map flattening (#35132)

The library used by ngcc to update the source files (MagicString) is able
to generate a source-map but it is not able to account for any previous
source-map that the input text is already associated with.

There have been various attempts to fix this but none have been very
successful, since it is not a trivial problem to solve.

This commit contains a novel approach that is able to load up a tree of
source-files connected by source-maps and flatten them down into a single
source-map that maps directly from the final generated file to the original
sources referenced by the intermediate source-maps.

PR Close #35132
This commit is contained in:
Pete Bacon Darwin
2020-02-16 21:07:30 +01:00
committed by Miško Hevery
parent 2a8dd4758c
commit df816c9c80
16 changed files with 1273 additions and 181 deletions

View File

@ -20,7 +20,7 @@ import {EntryPointBundle} from '../packages/entry_point_bundle';
import {Logger} from '../logging/logger';
import {FileToWrite, getImportRewriter} from './utils';
import {RenderingFormatter} from './rendering_formatter';
import {extractSourceMap, renderSourceAndMap} from './source_maps';
import {renderSourceAndMap} from './source_maps';
/**
* A structure that captures information about what needs to be rendered
@ -81,8 +81,7 @@ export class DtsRenderer {
}
renderDtsFile(dtsFile: ts.SourceFile, renderInfo: DtsRenderInfo): FileToWrite[] {
const input = extractSourceMap(this.fs, this.logger, dtsFile);
const outputText = new MagicString(input.source);
const outputText = new MagicString(dtsFile.text);
const printer = ts.createPrinter();
const importManager = new ImportManager(
getImportRewriter(this.bundle.dts !.r3SymbolsFile, this.bundle.isCore, false),
@ -112,7 +111,7 @@ export class DtsRenderer {
this.dtsFormatter.addImports(
outputText, importManager.getAllImports(dtsFile.fileName), dtsFile);
return renderSourceAndMap(dtsFile, input, outputText);
return renderSourceAndMap(this.fs, dtsFile, outputText);
}
private getTypingsFilesToRender(

View File

@ -18,7 +18,7 @@ import {NgccReflectionHost} from '../host/ngcc_host';
import {Logger} from '../logging/logger';
import {EntryPointBundle} from '../packages/entry_point_bundle';
import {RenderingFormatter, RedundantDecoratorMap} from './rendering_formatter';
import {extractSourceMap, renderSourceAndMap} from './source_maps';
import {renderSourceAndMap} from './source_maps';
import {FileToWrite, getImportRewriter, stripExtension} from './utils';
/**
@ -61,8 +61,7 @@ export class Renderer {
switchMarkerAnalysis: SwitchMarkerAnalysis|undefined,
privateDeclarationsAnalyses: PrivateDeclarationsAnalyses): FileToWrite[] {
const isEntryPoint = sourceFile === this.bundle.src.file;
const input = extractSourceMap(this.fs, this.logger, sourceFile);
const outputText = new MagicString(input.source);
const outputText = new MagicString(sourceFile.text);
if (switchMarkerAnalysis) {
this.srcFormatter.rewriteSwitchableDeclarations(
@ -115,7 +114,7 @@ export class Renderer {
}
if (compiledFile || switchMarkerAnalysis || isEntryPoint) {
return renderSourceAndMap(sourceFile, input, outputText);
return renderSourceAndMap(this.fs, sourceFile, outputText);
} else {
return [];
}

View File

@ -5,13 +5,13 @@
* 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 {SourceMapConverter, commentRegex, fromJSON, fromObject, fromSource, generateMapFileComment, mapFileCommentRegex, removeComments, removeMapFileComments} from 'convert-source-map';
import {SourceMapConverter, fromObject, generateMapFileComment} from 'convert-source-map';
import MagicString from 'magic-string';
import {RawSourceMap, SourceMapConsumer, SourceMapGenerator} from 'source-map';
import * as ts from 'typescript';
import {resolve, FileSystem, absoluteFromSourceFile, dirname, basename, absoluteFrom} from '../../../src/ngtsc/file_system';
import {Logger} from '../logging/logger';
import {FileSystem, absoluteFromSourceFile, basename, absoluteFrom} from '../../../src/ngtsc/file_system';
import {FileToWrite} from './utils';
import {SourceFileLoader} from '../sourcemaps/source_file_loader';
import {RawSourceMap} from '../sourcemaps/raw_source_map';
export interface SourceMapInfo {
source: string;
@ -19,117 +19,33 @@ export interface SourceMapInfo {
isInline: boolean;
}
/**
* Get the map from the source (note whether it is inline or external)
*/
export function extractSourceMap(
fs: FileSystem, logger: Logger, file: ts.SourceFile): SourceMapInfo {
const inline = commentRegex.test(file.text);
const external = mapFileCommentRegex.exec(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 {
const fileName = external[1] || external[2];
const filePath = resolve(dirname(absoluteFromSourceFile(file)), fileName);
const mappingFile = fs.readFile(filePath);
externalSourceMap = fromJSON(mappingFile);
} catch (e) {
if (e.code === 'ENOENT') {
logger.warn(
`The external map file specified in the source code comment "${e.path}" was not found on the file system.`);
const mapPath = absoluteFrom(file.fileName + '.map');
if (basename(e.path) !== basename(mapPath) && fs.exists(mapPath) &&
fs.stat(mapPath).isFile()) {
logger.warn(
`Guessing the map file name from the source file name: "${basename(mapPath)}"`);
try {
externalSourceMap = fromObject(JSON.parse(fs.readFile(mapPath)));
} catch (e) {
logger.error(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.
*/
export function renderSourceAndMap(
sourceFile: ts.SourceFile, input: SourceMapInfo, output: MagicString): FileToWrite[] {
const outputPath = absoluteFromSourceFile(sourceFile);
const outputMapPath = absoluteFrom(`${outputPath}.map`);
const relativeSourcePath = basename(outputPath);
const relativeMapPath = `${relativeSourcePath}.map`;
fs: FileSystem, sourceFile: ts.SourceFile, generatedMagicString: MagicString): FileToWrite[] {
const generatedPath = absoluteFromSourceFile(sourceFile);
const generatedMapPath = absoluteFrom(`${generatedPath}.map`);
const generatedContent = generatedMagicString.toString();
const generatedMap: RawSourceMap = generatedMagicString.generateMap(
{file: generatedPath, source: generatedPath, includeContent: true});
const outputMap = output.generateMap({
source: outputPath,
includeContent: true,
// hires: true // TODO: This results in accurate but huge sourcemaps. Instead we should fix
// the merge algorithm.
});
const loader = new SourceFileLoader(fs);
const generatedFile = loader.loadSourceFile(
generatedPath, generatedContent, {map: generatedMap, mapPath: generatedMapPath});
// we must set this after generation as magic string does "manipulation" on the path
outputMap.file = relativeSourcePath;
const rawMergedMap: RawSourceMap = generatedFile.renderFlattenedSourceMap();
const mergedMap = fromObject(rawMergedMap);
const mergedMap =
mergeSourceMaps(input.map && input.map.toObject(), JSON.parse(outputMap.toString()));
const result: FileToWrite[] = [];
if (input.isInline) {
result.push({path: outputPath, contents: `${output.toString()}\n${mergedMap.toComment()}`});
if (generatedFile.sources[0]?.inline) {
// The input source-map was inline so make the output one inline too.
return [{path: generatedPath, contents: `${generatedFile.contents}\n${mergedMap.toComment()}`}];
} else {
result.push({
path: outputPath,
contents: `${output.toString()}\n${generateMapFileComment(relativeMapPath)}`
});
result.push({path: outputMapPath, contents: mergedMap.toJSON()});
const sourceMapComment = generateMapFileComment(`${basename(generatedPath)}.map`);
return [
{path: generatedPath, contents: `${generatedFile.contents}\n${sourceMapComment}`},
{path: generatedMapPath, contents: mergedMap.toJSON()}
];
}
return result;
}
/**
* 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;
}