angular-cli/lib/broccoli/broccoli-typescript.js

327 lines
12 KiB
JavaScript

'use strict';
const Plugin = require('broccoli-caching-writer');
const fs = require('fs');
const fse = require('fs-extra');
const path = require('path');
const ts = require('typescript');
const FS_OPTS = {
encoding: 'utf-8'
};
/**
* Broccoli plugin that implements incremental Typescript compiler.
*
* It instantiates a typescript compiler instance that keeps all the state about the project and
* can re-emit only the files that actually changed.
*
* Limitations: only files that map directly to the changed source file via naming conventions are
* re-emitted. This primarily affects code that uses `const enum`s, because changing the enum value
* requires global emit, which can affect many files.
*/
class BroccoliTypeScriptCompiler extends Plugin {
constructor(inputPath, options) {
super([inputPath], {});
this._fileRegistry = Object.create(null);
this._rootfilePaths = [];
this._tsOpts = null;
this._tsServiceHost = null;
this._tsService = null;
this._options = options;
if (options.files) {
this._rootFilePaths = options.files.splice(0);
}
}
build() {
if (!this._tsServiceHost) {
this._createServiceHost();
this._doFullBuild();
} else {
this._doIncrementalBuild();
}
}
_doIncrementalBuild() {
var pathsWithErrors = [];
var errorMessages = [];
var entries = this.listEntries();
const inputPath = this.inputPaths[0];
const pathsToEmit = [];
entries.forEach(entry => {
const tsFilePath = path.join(inputPath, entry.relativePath);
if (!tsFilePath.match(/\.ts$/) || !fs.existsSync(tsFilePath)) {
return;
}
if (!this._fileRegistry[tsFilePath]) {
// Not in the registry? Add it.
this._addNewFileEntry(entry);
// We need to add the file to the rootFiles as well, as otherwise it _might_
// not get compiled. It needs to be referenced at some point, and unless we
// add the spec files first (which we don't know the order), it won't.
// So make every new files an entry point instead.
// TODO(hansl): We need to investigate if we should emit files that are not
// referenced. This doesn't take that into account.
this._tsServiceHost.fileNames.push(tsFilePath);
pathsToEmit.push(tsFilePath);
} else if (this._fileRegistry[tsFilePath].version >= entry.mtime) {
// Nothing to do for this file. Just link the cached outputs.
this._fileRegistry[tsFilePath].outputs.forEach(absoluteFilePath => {
const outputFilePath = absoluteFilePath.replace(this.cachePath, this.outputPath);
fse.mkdirsSync(path.dirname(outputFilePath));
fs.linkSync(absoluteFilePath, outputFilePath);
});
return;
} else {
this._fileRegistry[tsFilePath].version = entry.mtime;
pathsToEmit.push(tsFilePath);
}
});
if (pathsToEmit.length > 0) {
// Force the TS Service to recreate the program (ie. call synchronizeHostData).
this._tsServiceHost.projectVersion++;
pathsToEmit.forEach(tsFilePath => {
var output = this._tsService.getEmitOutput(tsFilePath);
if (output.emitSkipped) {
var errorFound = this.collectErrors(tsFilePath);
if (errorFound) {
pathsWithErrors.push(tsFilePath);
errorMessages.push(errorFound);
}
} else {
output.outputFiles.forEach(o => {
this._outputFile(o.name, o.text, this._fileRegistry[tsFilePath]);
});
}
});
}
if (pathsWithErrors.length) {
this.previousRunFailed = true;
var error = new Error('Typescript found the following errors:\n' + errorMessages.join('\n'));
error['showStack'] = false;
throw error;
} else if (this.previousRunFailed) {
this._doFullBuild();
}
}
_createServiceHost() {
// the conversion is a bit awkward, see https://github.com/Microsoft/TypeScript/issues/5276
// in 1.8 use convertCompilerOptionsFromJson
this._tsOpts = ts.parseJsonConfigFileContent(this._options, null, null).options;
this._tsOpts.rootDir = '';
this._tsOpts.outDir = '';
this._tsServiceHost = new CustomLanguageServiceHost(
this._tsOpts, this._rootFilePaths, this._fileRegistry, this.inputPaths[0]);
this._tsService = ts.createLanguageService(this._tsServiceHost, ts.createDocumentRegistry());
}
collectErrors(tsFilePath) {
var allDiagnostics = this._tsService.getCompilerOptionsDiagnostics()
.concat(this._tsService.getSyntacticDiagnostics(tsFilePath))
.concat(this._tsService.getSemanticDiagnostics(tsFilePath));
var errors = [];
allDiagnostics.forEach(diagnostic => {
var message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
if (diagnostic.file) {
var _a = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start), line = _a.line, character = _a.character;
errors.push(' ' + diagnostic.file.fileName + ' (' + (line + 1) + ',' + (character + 1) + '): ' + message);
} else {
errors.push(' Error: ' + message);
}
});
if (errors.length) {
return errors.join('\n');
}
}
_doFullBuild() {
var program = this._tsService.getProgram();
// Add file registry.
var allFiles = program.getSourceFiles();
// Find the entry, update the registry.
let allEntries = this.listEntries();
allEntries.forEach(entry => this._addNewFileEntry(entry));
allFiles.forEach(sourceFile => {
const registry = this._fileRegistry[path.resolve(this.inputPaths[0], sourceFile.fileName)];
const emitResult = program.emit(sourceFile, (absoluteFilePath, fileContent) => {
this._outputFile(absoluteFilePath, fileContent, registry);
});
if (emitResult.emitSkipped) {
var allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics);
var errorMessages_1 = [];
allDiagnostics.forEach(function (diagnostic) {
var pos = '';
if (diagnostic.file) {
var _a = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start), line = _a.line, character = _a.character;
pos = diagnostic.file.fileName + ' (' + (line + 1) + ', ' + (character + 1) + '): ';
}
var message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
errorMessages_1.push(' ' + pos + message);
});
if (errorMessages_1.length) {
this.previousRunFailed = true;
var error = new Error('Typescript found the following errors:\n' + errorMessages_1.join('\n'));
error['showStack'] = false;
throw error;
} else {
this.previousRunFailed = false;
}
}
});
}
_outputFile(absoluteFilePath, fileContent, registry) {
absoluteFilePath = path.resolve(this.cachePath, absoluteFilePath);
// Replace the input path by the output.
absoluteFilePath = absoluteFilePath.replace(this.inputPaths[0], this.cachePath);
const outputFilePath = absoluteFilePath.replace(this.cachePath, this.outputPath);
if (registry) {
registry.outputs.add(absoluteFilePath);
}
fse.mkdirsSync(path.dirname(absoluteFilePath));
const content = this.fixSourceMapSources(fileContent);
fs.writeFileSync(absoluteFilePath, content, FS_OPTS);
fse.mkdirsSync(path.dirname(outputFilePath));
fs.linkSync(absoluteFilePath, outputFilePath);
}
_addNewFileEntry(entry) {
const p = path.join(this.inputPaths[0], entry.relativePath);
if (this._fileRegistry[p]) {
throw new Error('Trying to add a new entry to an already existing one: ' + p);
}
this._fileRegistry[p] = {
version: entry.mtime,
outputs: new Set()
};
}
/**
* There is a bug in TypeScript 1.6, where the sourceRoot and inlineSourceMap properties
* are exclusive. This means that the sources property always contains relative paths
* (e.g, ../../../../angular2/src/di/injector.ts).
*
* Here, we normalize the sources property and remove the ../../../
*
* This issue is fixed in https://github.com/Microsoft/TypeScript/pull/5620.
* Once we switch to TypeScript 1.8, we can remove this method.
*/
fixSourceMapSources(content) {
try {
var marker = '//# sourceMappingURL=data:application/json;base64,';
var index = content.indexOf(marker);
if (index == -1)
return content;
var base = content.substring(0, index + marker.length);
var sourceMapBit = new Buffer(content.substring(index + marker.length), 'base64').toString('utf8');
var sourceMaps = JSON.parse(sourceMapBit);
var source = sourceMaps.sources[0];
sourceMaps.sources = [source.substring(source.lastIndexOf('../') + 3)];
return '' + base + new Buffer(JSON.stringify(sourceMaps)).toString('base64');
} catch (e) {
return content;
}
}
}
class CustomLanguageServiceHost {
constructor(compilerOptions, fileNames, fileRegistry, treeInputPath) {
this.compilerOptions = compilerOptions;
this.fileNames = fileNames;
this.fileRegistry = fileRegistry;
this.treeInputPath = treeInputPath;
this.currentDirectory = treeInputPath;
this.defaultLibFilePath = ts.getDefaultLibFilePath(compilerOptions).replace(/\\/g, '/');
this.projectVersion = 0;
}
getScriptFileNames() {
return this.fileNames;
}
getScriptVersion(fileName) {
fileName = path.resolve(this.treeInputPath, fileName);
return this.fileRegistry[fileName] && this.fileRegistry[fileName].version.toString();
}
getProjectVersion() {
return this.projectVersion.toString();
}
/**
* This method is called quite a bit to lookup 3 kinds of paths:
* 1/ files in the fileRegistry
* - these are the files in our project that we are watching for changes
* - in the future we could add caching for these files and invalidate the cache when
* the file is changed lazily during lookup
* 2/ .d.ts and library files not in the fileRegistry
* - these are not our files, they come from tsd or typescript itself
* - these files change only rarely but since we need them very rarely, it's not worth the
* cache invalidation hassle to cache them
* 3/ bogus paths that typescript compiler tries to lookup during import resolution
* - these paths are tricky to cache since files come and go and paths that was bogus in the
* past might not be bogus later
*
* In the initial experiments the impact of this caching was insignificant (single digit %) and
* not worth the potential issues with stale cache records.
*/
getScriptSnapshot(tsFilePath) {
var absoluteTsFilePath;
if (tsFilePath == this.defaultLibFilePath || path.isAbsolute(tsFilePath)) {
absoluteTsFilePath = tsFilePath;
} else if (this.compilerOptions.moduleResolution === 2 /* NodeJs */ &&
tsFilePath.match(/^node_modules/)) {
absoluteTsFilePath = path.resolve(tsFilePath);
} else if (tsFilePath.match(/^rxjs/)) {
absoluteTsFilePath = path.resolve('node_modules', tsFilePath);
} else {
absoluteTsFilePath = path.join(this.treeInputPath, tsFilePath);
}
if (!fs.existsSync(absoluteTsFilePath)) {
// TypeScript seems to request lots of bogus paths during import path lookup and resolution,
// so we we just return undefined when the path is not correct.
return undefined;
}
return ts.ScriptSnapshot.fromString(fs.readFileSync(absoluteTsFilePath, FS_OPTS));
}
getCurrentDirectory() {
return this.currentDirectory;
}
getCompilationSettings() {
return this.compilerOptions;
}
getDefaultLibFileName(/* options */) {
// ignore options argument, options should not change during the lifetime of the plugin
return this.defaultLibFilePath;
}
}
module.exports = BroccoliTypeScriptCompiler;