angular-cn/tools/broccoli/broccoli-typescript.ts

294 lines
11 KiB
TypeScript

/// <reference path="../typings/node/node.d.ts" />
import fs = require('fs');
import fse = require('fs-extra');
import path = require('path');
import * as ts from 'typescript';
import {wrapDiffingPlugin, DiffingBroccoliPlugin, DiffResult} from './diffing-broccoli-plugin';
type FileRegistry = ts.Map<{version: number}>;
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 DiffingTSCompiler implements DiffingBroccoliPlugin {
private tsOpts: ts.CompilerOptions;
private fileRegistry: FileRegistry = Object.create(null);
private rootFilePaths: string[];
private tsServiceHost: ts.LanguageServiceHost;
private tsService: ts.LanguageService;
private firstRun: boolean = true;
private previousRunFailed: boolean = false;
static includeExtensions = ['.ts'];
static excludeExtensions = ['.d.ts'];
constructor(public inputPath: string, public cachePath: string, public options) {
if (options.rootFilePaths) {
this.rootFilePaths = options.rootFilePaths.splice(0);
delete options.rootFilePaths;
} else {
this.rootFilePaths = [];
}
// in tsc 1.7.x this api was renamed to parseJsonConfigFileContent
// the conversion is a bit awkward, see https://github.com/Microsoft/TypeScript/issues/5276
this.tsOpts = ts.parseConfigFile({compilerOptions: options, files: []}, null, null).options;
// TODO: the above turns rootDir set to './' into an empty string - looks like a tsc bug
// check back when we upgrade to 1.7.x
if (this.tsOpts.rootDir === '') {
this.tsOpts.rootDir = './';
}
this.tsOpts.outDir = this.cachePath;
this.tsServiceHost = new CustomLanguageServiceHost(this.tsOpts, this.rootFilePaths,
this.fileRegistry, this.inputPath);
this.tsService = ts.createLanguageService(this.tsServiceHost, ts.createDocumentRegistry());
}
rebuild(treeDiff: DiffResult) {
let pathsToEmit = [];
let pathsWithErrors = [];
let errorMessages = [];
treeDiff.addedPaths.concat(treeDiff.changedPaths)
.forEach((tsFilePath) => {
if (!this.fileRegistry[tsFilePath]) {
this.fileRegistry[tsFilePath] = {version: 0};
this.rootFilePaths.push(tsFilePath);
} else {
this.fileRegistry[tsFilePath].version++;
}
pathsToEmit.push(tsFilePath);
});
treeDiff.removedPaths.forEach((tsFilePath) => {
console.log('removing outputs for', tsFilePath);
this.rootFilePaths.splice(this.rootFilePaths.indexOf(tsFilePath), 1);
this.fileRegistry[tsFilePath] = null;
this.removeOutputFor(tsFilePath);
});
if (this.firstRun) {
this.firstRun = false;
this.doFullBuild();
} else {
pathsToEmit.forEach((tsFilePath) => {
let output = this.tsService.getEmitOutput(tsFilePath);
if (output.emitSkipped) {
let errorFound = this.collectErrors(tsFilePath);
if (errorFound) {
pathsWithErrors.push(tsFilePath);
errorMessages.push(errorFound);
}
} else {
output.outputFiles.forEach(o => {
let destDirPath = path.dirname(o.name);
fse.mkdirsSync(destDirPath);
fs.writeFileSync(o.name, this.fixSourceMapSources(o.text), FS_OPTS);
});
}
});
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();
}
}
}
private collectErrors(tsFilePath): String {
let allDiagnostics = this.tsService.getCompilerOptionsDiagnostics()
.concat(this.tsService.getSyntacticDiagnostics(tsFilePath))
.concat(this.tsService.getSemanticDiagnostics(tsFilePath));
let errors = [];
allDiagnostics.forEach(diagnostic => {
let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
if (diagnostic.file) {
let {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
errors.push(` ${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`);
} else {
errors.push(` Error: ${message}`);
}
});
if (errors.length) {
return errors.join('\n');
}
}
private doFullBuild() {
let program = this.tsService.getProgram();
let emitResult = program.emit(undefined, (absoluteFilePath, fileContent) => {
fse.mkdirsSync(path.dirname(absoluteFilePath));
fs.writeFileSync(absoluteFilePath, this.fixSourceMapSources(fileContent), FS_OPTS);
});
if (emitResult.emitSkipped) {
let allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics);
let errorMessages = [];
allDiagnostics.forEach(diagnostic => {
var pos = '';
if (diagnostic.file) {
var {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
pos = `${diagnostic.file.fileName} (${line + 1}, ${character + 1}): `
}
var message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
errorMessages.push(` ${pos}${message}`);
});
if (errorMessages.length) {
this.previousRunFailed = true;
var error =
new Error('Typescript found the following errors:\n' + errorMessages.join('\n'));
error['showStack'] = false;
throw error;
} else {
this.previousRunFailed = false;
}
}
}
/**
* 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.
*/
private fixSourceMapSources(content: string): string {
try {
const marker = "//# sourceMappingURL=data:application/json;base64,";
const index = content.indexOf(marker);
if (index == -1) return content;
const base = content.substring(0, index + marker.length);
const sourceMapBit =
new Buffer(content.substring(index + marker.length), 'base64').toString("utf8");
const sourceMaps = JSON.parse(sourceMapBit);
const source = sourceMaps.sources[0];
sourceMaps.sources = [source.substring(source.lastIndexOf("../") + 3)];
return `${base}${new Buffer(JSON.stringify(sourceMaps)).toString('base64')}`;
} catch (e) {
return content;
}
}
private removeOutputFor(tsFilePath: string) {
let absoluteJsFilePath = path.join(this.cachePath, tsFilePath.replace(/\.ts$/, '.js'));
let absoluteMapFilePath = path.join(this.cachePath, tsFilePath.replace(/.ts$/, '.js.map'));
let absoluteDtsFilePath = path.join(this.cachePath, tsFilePath.replace(/\.ts$/, '.d.ts'));
if (fs.existsSync(absoluteJsFilePath)) {
fs.unlinkSync(absoluteJsFilePath);
fs.unlinkSync(absoluteMapFilePath);
fs.unlinkSync(absoluteDtsFilePath);
}
}
}
class CustomLanguageServiceHost implements ts.LanguageServiceHost {
private currentDirectory: string;
private defaultLibFilePath: string;
constructor(private compilerOptions: ts.CompilerOptions, private fileNames: string[],
private fileRegistry: FileRegistry, private treeInputPath: string) {
this.currentDirectory = process.cwd();
this.defaultLibFilePath = ts.getDefaultLibFilePath(compilerOptions).replace(/\\/g, '/');
}
getScriptFileNames(): string[] { return this.fileNames; }
getScriptVersion(fileName: string): string {
return this.fileRegistry[fileName] && this.fileRegistry[fileName].version.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: string): ts.IScriptSnapshot {
let absoluteTsFilePath;
if (tsFilePath == this.defaultLibFilePath || path.isAbsolute(tsFilePath)) {
absoluteTsFilePath = tsFilePath;
} else if (this.compilerOptions.moduleResolution === ts.ModuleResolutionKind.NodeJs &&
tsFilePath.match(/^node_modules/)) {
absoluteTsFilePath = path.resolve(tsFilePath);
} else if (tsFilePath.match(/^@reactivex/)) {
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(): string { return this.currentDirectory; }
getCompilationSettings(): ts.CompilerOptions { return this.compilerOptions; }
getDefaultLibFileName(options: ts.CompilerOptions): string {
// ignore options argument, options should not change during the lifetime of the plugin
return this.defaultLibFilePath;
}
}
export default wrapDiffingPlugin(DiffingTSCompiler);