diff --git a/src/builder.ts b/src/builder.ts index a8aa271..57db348 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -8,6 +8,7 @@ import { EOL } from "os"; import { log, colors } from 'gulp-util'; import * as ts from 'typescript'; import Vinyl = require('vinyl'); +import { EmitAndSemanticDiagnosticsBuilderProgram } from 'typescript'; export interface IConfiguration { json: boolean; @@ -23,15 +24,27 @@ export interface CancellationToken { } export namespace CancellationToken { - export const None: CancellationToken = { - isCancellationRequested() { return false } + export const None: ts.CancellationToken = { + isCancellationRequested() { return false }, + throwIfCancellationRequested: () => { } }; + + export function createTsCancellationToken(token: CancellationToken): ts.CancellationToken { + return { + isCancellationRequested: () => token.isCancellationRequested(), + throwIfCancellationRequested: () => { + if (token.isCancellationRequested()) { + throw new ts.OperationCanceledException(); + } + } + }; + } } export interface ITypeScriptBuilder { build(out: (file: Vinyl) => void, onError: (err: any) => void, token?: CancellationToken): Promise; file(file: Vinyl): void; - languageService: ts.LanguageService; + getProgram(): ts.Program; } function normalize(path: string): string { @@ -46,69 +59,78 @@ function fixCompilerOptions(config: IConfiguration, compilerOptions: ts.Compiler delete compilerOptions.sourceRoot; // incompatible with gulp-sourcemaps delete compilerOptions.mapRoot; // incompatible with gulp-sourcemaps delete compilerOptions.outDir; // always emit relative to source file - compilerOptions.declaration = true; // always emit declaration files return compilerOptions; } export function createTypeScriptBuilder(config: IConfiguration, compilerOptions: ts.CompilerOptions): ITypeScriptBuilder { - // fix compiler options const originalCompilerOptions = utils.collections.structuredClone(compilerOptions); compilerOptions = fixCompilerOptions(config, utils.collections.structuredClone(compilerOptions)); + const host = createHost(compilerOptions, config.noFilesystemLookup || false); + + const newLine = getNewLine(); + let emitSourceMapsInStream = true; - let host = new LanguageServiceHost(compilerOptions, config.noFilesystemLookup || false), - service = ts.createLanguageService(host, ts.createDocumentRegistry()), - lastBuildVersion: { [path: string]: string } = Object.create(null), - lastDtsHash: { [path: string]: string } = Object.create(null), - userWantsDeclarations = compilerOptions.declaration, - oldErrors: { [path: string]: ts.Diagnostic[] } = Object.create(null), - headUsed = process.memoryUsage().heapUsed, - emitSourceMapsInStream = true; + // Creates/ synchronizes the program + let watch: ts.WatchOfFilesAndCompilerOptions; + let fileListChanged = false; - // always emit declaraction files - host.getCompilationSettings().declaration = true; + // Program and builder to emit/check files + let headUsed = process.memoryUsage().heapUsed; + return { + file, + build, + getProgram: () => getBuilderProgram().getProgram() + }; - function _log(topic: string, message: string): void { + function _log(topic: string, message: string) { if (config.verbose) { log(colors.cyan(topic), message); } } - function printDiagnostic(diag: ts.Diagnostic, onError: (err: any) => void): void { - - var lineAndCh = diag.file.getLineAndCharacterOfPosition(diag.start), - message: string; - - if (!config.json) { - message = utils.strings.format('{0}({1},{2}): {3}', - diag.file.fileName, - lineAndCh.line + 1, - lineAndCh.character + 1, - ts.flattenDiagnosticMessageText(diag.messageText, '\n')); - - } else { - message = JSON.stringify({ - filename: diag.file.fileName, - offset: diag.start, - length: diag.length, - message: ts.flattenDiagnosticMessageText(diag.messageText, '\n') + function printDiagnostics(diagnostics: ReadonlyArray, onError: (err: any) => void) { + if (diagnostics.length > 0) { + diagnostics.forEach(diag => { + let message: string; + if (diag.file) { + let lineAndCh = diag.file.getLineAndCharacterOfPosition(diag.start); + if (!config.json) { + message = utils.strings.format('{0}({1},{2}): {3}', + diag.file.fileName, + lineAndCh.line + 1, + lineAndCh.character + 1, + ts.flattenDiagnosticMessageText(diag.messageText, '\n')); + + } else { + message = JSON.stringify({ + filename: diag.file.fileName, + offset: diag.start, + length: diag.length, + message: ts.flattenDiagnosticMessageText(diag.messageText, '\n') + }); + } + } + else { + message = ts.flattenDiagnosticMessageText(diag.messageText, '\n'); + if (config.json) { + message = JSON.stringify({ + message + }); + } + } + onError(message); }); } - - onError(message); } - function file(file: Vinyl): void { + function file(file: Vinyl) { // support gulp-sourcemaps if ((file).sourceMap) { emitSourceMapsInStream = false; } - if (!file.contents) { - host.removeScriptSnapshot(file.path); - } else { - host.addScriptSnapshot(file.path, new ScriptSnapshot(file)); - } + fileListChanged = (!file.contents ? host.removeFile(file.path) : host.addFile(file)) || fileListChanged; } function getNewLine() { @@ -119,604 +141,522 @@ export function createTypeScriptBuilder(config: IConfiguration, compilerOptions: } } - function isExternalModule(sourceFile: ts.SourceFile): boolean { - return (sourceFile).externalModuleIndicator - || /declare\s+module\s+('|")(.+)\1/.test(sourceFile.getText()) + function createPromise(arg: T, tsToken: ts.CancellationToken, action: (arg: T, tsToken: ts.CancellationToken) => U, onfulfilled: (result: U) => void, workOnNext: () => void) { + return new Promise(resolve => { + process.nextTick(function () { + resolve(action(arg, tsToken)); + }); + }).then(onfulfilled, err => { + if (err instanceof ts.OperationCanceledException) { + _log('[CANCEL]', '>>This compile run was cancelled<<'); + } + console.error(err); + }).then(() => { + // After completion, schedule next work + process.nextTick(workOnNext); + }).catch(err => { + console.error(err); + }); } - function build(out: (file: Vinyl) => void, onError: (err: any) => void, token = CancellationToken.None): Promise { - - function checkSyntaxSoon(fileName: string): Promise { - return new Promise(resolve => { - process.nextTick(function () { - resolve(service.getSyntacticDiagnostics(fileName)); - }); - }); + function getBuilderProgram() { + // Create/update the program + if (!watch) { + host.rootFiles = host.getFileNames(); + host.options = compilerOptions; + watch = ts.createWatchProgram(host); } - - function checkSemanticsSoon(fileName: string): Promise { - return new Promise(resolve => { - process.nextTick(function () { - resolve(service.getSemanticDiagnostics(fileName)); - }); - }); + else if (fileListChanged) { + fileListChanged = false; + watch.updateRootFileNames(host.getFileNames()); } + return watch.getProgram(); + } - function emitSoon(fileName: string): Promise<{ fileName: string, signature: string, files: Vinyl[] }> { - - return new Promise(resolve => { - process.nextTick(function () { - - if (/\.d\.ts$/.test(fileName)) { - // if it's already a d.ts file just emit it signature - let snapshot = host.getScriptSnapshot(fileName); - let signature = crypto.createHash('md5') - .update(snapshot.getText(0, snapshot.getLength())) - .digest('base64'); - - return resolve({ - fileName, - signature, - files: [] - }); - } - - let input = host.getScriptSnapshot(fileName); - let output = service.getEmitOutput(fileName); - let files: Vinyl[] = []; - let signature: string; - let javaScriptFile: Vinyl; - let declarationFile: Vinyl; - let sourceMapFile: Vinyl; - - for (let file of output.outputFiles) { - // When gulp-sourcemaps writes out a sourceMap, it uses the path - // information of the associated file. Specifically, it uses the base - // directory and relative path of the file to make decisions on how to - // write the "sources" and "sourceRoot" properties. - // - // To emit the correct paths, we need to have the output files emulate - // a path local to the source location, not the expected output location. - // - // Since gulp.dest sets our output location for us, then all that matters - // to gulp.dest is the relative path for each file. This means that we - // should be able to safely treat output files as local to sources to - // better support gulp-sourcemaps. - - let base = !config._emitWithoutBasePath ? input.getBase() : undefined; - let relative = base && path.relative(base, file.name); - let name = relative ? path.resolve(base, relative) : file.name; - let contents = new Buffer(file.text); - let vinyl = new Vinyl({ path: name, base, contents }); - if (/\.js$/.test(vinyl.path)) { - javaScriptFile = vinyl; - } - else if (/\.js\.map$/.test(vinyl.path)) { - sourceMapFile = vinyl; - } - else if (/\.d\.ts$/.test(vinyl.path)) { - declarationFile = vinyl; - } - } + function build(out: (file: Vinyl) => void, onError: (err: any) => void, token?: CancellationToken): Promise { + let t1 = Date.now(); - if (javaScriptFile) { - // gulp-sourcemaps will add an appropriate sourceMappingURL comment, so we need to remove the - // one that TypeScript generates. - const sourceMappingURLPattern = /(\r\n?|\n)?\/\/# sourceMappingURL=[^\r\n]+(?=[\r\n\s]*$)/; - const contents = javaScriptFile.contents.toString(); - javaScriptFile.contents = new Buffer(contents.replace(sourceMappingURLPattern, "")); - files.push(javaScriptFile); - } + enum Status { None, SyntaxCheck, SemanticCheck } + let toCheckSyntaxOf: ts.SourceFile | undefined; + let toCheckSemanticOf: ts.SourceFile | undefined; + let sourceFilesToCheck: ts.SourceFile[] | undefined; + let unrecoverableError = false; + let rootFileNames: string[]; + let requireAffectedFileToBeRoot = watch === undefined; + // Check only root file names - as thats what earlier happened + let requireRootForOtherFiles = true; + let hasPendingEmit = true; + const tsToken = token ? CancellationToken.createTsCancellationToken(token) : CancellationToken.None; + let builderProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram; - if (declarationFile) { - signature = crypto.createHash('md5') - .update(declarationFile.contents as Buffer) - .digest('base64'); + return new Promise(resolve => { + rootFileNames = host.getFileNames(); + // Create/update the program + builderProgram = getBuilderProgram(); + host.updateWithProgram(builderProgram); - if (originalCompilerOptions.declaration) { - // don't leak .d.ts files if users don't want them - files.push(declarationFile); - } - } + // Schedule next work + sourceFilesToCheck = builderProgram.getSourceFiles().slice(); + workOnNext(); - if (sourceMapFile) { - // adjust the source map to be relative to the source directory. - let sourceMap = JSON.parse(sourceMapFile.contents.toString()); - let sourceRoot = sourceMap.sourceRoot; - let sources = sourceMap.sources.map(source => path.resolve(sourceMapFile.base, source)); - let destPath = path.resolve(config.base, originalCompilerOptions.outDir || "."); - - // update sourceRoot to be relative from the expected destination path - sourceRoot = emitSourceMapsInStream ? originalCompilerOptions.sourceRoot : sourceRoot; - sourceMap.sourceRoot = sourceRoot ? normalize(path.relative(destPath, sourceRoot)) : undefined; - - if (emitSourceMapsInStream) { - // update sourcesContent - if (originalCompilerOptions.inlineSources) { - sourceMap.sourcesContent = sources.map(source => { - const snapshot = host.getScriptSnapshot(source) || input; - const vinyl = snapshot && snapshot.getFile(); - return vinyl - ? (vinyl.contents).toString("utf8") - : ts.sys.readFile(source); - }); - } - - // make all sources relative to the sourceRoot or destPath - sourceMap.sources = sources.map(source => { - source = path.resolve(sourceMapFile.base, source); - source = path.relative(sourceRoot || destPath, source); - source = normalize(source); - return source; - }); - - // update the contents for the sourcemap file - sourceMapFile.contents = new Buffer(JSON.stringify(sourceMap)); - - const newLine = getNewLine(); - let contents = javaScriptFile.contents.toString(); - if (originalCompilerOptions.inlineSourceMap) { - // restore the sourcemap as an inline source map in the javaScript file. - contents += newLine + "//# sourceMappingURL=data:application/json;charset=utf8;base64," + sourceMapFile.contents.toString("base64") + newLine; - } - else { - contents += newLine + "//# sourceMappingURL=" + normalize(path.relative(path.dirname(javaScriptFile.path), sourceMapFile.path)) + newLine; - files.push(sourceMapFile); - } - - javaScriptFile.contents = new Buffer(contents); - } - else { - // sourcesContent is handled by gulp-sourcemaps - sourceMap.sourcesContent = undefined; - - // make all of the sources in the source map relative paths - sourceMap.sources = sources.map(source => { - const snapshot = host.getScriptSnapshot(source) || input; - const vinyl = snapshot && snapshot.getFile(); - return vinyl ? normalize(vinyl.relative) : source; - }); - - (javaScriptFile).sourceMap = sourceMap; - } - } + function workOnNext() { + if (!getNextWork(workOnNext)) { + resolve(); + } + } + }).then(() => { + // print stats + if (config.verbose) { + var headNow = process.memoryUsage().heapUsed, + MB = 1024 * 1024; + log('[tsb]', + 'time:', colors.yellow((Date.now() - t1) + 'ms'), + 'mem:', colors.cyan(Math.ceil(headNow / MB) + 'MB'), colors.bgCyan('Δ' + Math.ceil((headNow - headUsed) / MB))); + headUsed = headNow; + } + }); - resolve({ - fileName, - signature, - files - }); - }); - }); + function getSyntacticDiagnostics(file: ts.SourceFile, token: ts.CancellationToken) { + return builderProgram.getSyntacticDiagnostics(file, token); } - let newErrors: { [path: string]: ts.Diagnostic[] } = Object.create(null); - let t1 = Date.now(); + function getSemanticDiagnostics(file: ts.SourceFile, token: ts.CancellationToken) { + return builderProgram.getSemanticDiagnostics(file, token); + } - let toBeEmitted: string[] = []; - let toBeCheckedSyntactically: string[] = []; - let toBeCheckedSemantically: string[] = []; - let filesWithChangedSignature: string[] = []; - let dependentFiles: string[] = []; - let newLastBuildVersion = new Map(); + function emitNextAffectedFile(_arg: undefined, token: ts.CancellationToken) { + let files: Vinyl[] = []; - for (let fileName of host.getScriptFileNames()) { - if (lastBuildVersion[fileName] !== host.getScriptVersion(fileName)) { + let javaScriptFile: Vinyl; + let sourceMapFile: Vinyl; - toBeEmitted.push(fileName); - toBeCheckedSyntactically.push(fileName); - toBeCheckedSemantically.push(fileName); + const result = builderProgram.emitNextAffectedFile(writeFile, token); + if (!result) { + return undefined; } - } - return new Promise(resolve => { + const { result: { diagnostics }, affected } = result; + if (sourceMapFile) { + // adjust the source map to be relative to the source directory. + const sourceMap = JSON.parse(sourceMapFile.contents.toString()); + let sourceRoot = sourceMap.sourceRoot; + const sources = sourceMap.sources.map(source => path.resolve(sourceMapFile.base, source)); + const destPath = path.resolve(config.base, originalCompilerOptions.outDir || "."); + + // update sourceRoot to be relative from the expected destination path + sourceRoot = emitSourceMapsInStream ? originalCompilerOptions.sourceRoot : sourceRoot; + sourceMap.sourceRoot = sourceRoot ? normalize(path.relative(destPath, sourceRoot)) : undefined; + + if (emitSourceMapsInStream) { + // update sourcesContent + if (originalCompilerOptions.inlineSources) { + sourceMap.sourcesContent = sources.map(source => { + const vinyl = host.getFile(source); + return vinyl ? (vinyl.contents).toString("utf8") : ts.sys.readFile(source); + }); + } - let semanticCheckInfo = new Map(); - let seenAsDependentFile = new Set(); + // make all sources relative to the sourceRoot or destPath + sourceMap.sources = sources.map(source => { + source = path.resolve(sourceMapFile.base, source); + source = path.relative(sourceRoot || destPath, source); + source = normalize(source); + return source; + }); - function workOnNext() { + // update the contents for the sourcemap file + sourceMapFile.contents = new Buffer(JSON.stringify(sourceMap)); - let promise: Promise; - let fileName: string; + let contents = javaScriptFile.contents.toString(); + if (originalCompilerOptions.inlineSourceMap) { + // restore the sourcemap as an inline source map in the javaScript file. + contents += newLine + "//# sourceMappingURL=data:application/json;charset=utf8;base64," + sourceMapFile.contents.toString("base64") + newLine; + } + else { + contents += newLine + "//# sourceMappingURL=" + normalize(path.relative(path.dirname(javaScriptFile.path), sourceMapFile.path)) + newLine; + files.push(sourceMapFile); + } - // someone told us to stop this - if (token.isCancellationRequested()) { - _log('[CANCEL]', '>>This compile run was cancelled<<') - newLastBuildVersion.clear(); - resolve(); - return; + javaScriptFile.contents = new Buffer(contents); } + else { + // sourcesContent is handled by gulp-sourcemaps + sourceMap.sourcesContent = undefined; - // (1st) emit code - else if (toBeEmitted.length) { - fileName = toBeEmitted.pop(); - promise = emitSoon(fileName).then(value => { + // make all of the sources in the source map relative paths + sourceMap.sources = sources.map(source => { + const vinyl = host.getFile(source); + return vinyl ? normalize(vinyl.relative) : source; + }); - for (let file of value.files) { - _log('[emit code]', file.path); - out(file); - } + (javaScriptFile).sourceMap = sourceMap; + } + } - // remember when this was build - newLastBuildVersion.set(fileName, host.getScriptVersion(fileName)); + return { affected, files, diagnostics }; + + function writeFile(fileName: string, text: string, _writeByteOrderMark: boolean, _onError: (message: string) => void, sourceFiles: ts.SourceFile[]) { + // When gulp-sourcemaps writes out a sourceMap, it uses the path + // information of the associated file. Specifically, it uses the base + // directory and relative path of the file to make decisions on how to + // write the "sources" and "sourceRoot" properties. + // + // To emit the correct paths, we need to have the output files emulate + // a path local to the source location, not the expected output location. + // + // Since gulp.dest sets our output location for us, then all that matters + // to gulp.dest is the relative path for each file. This means that we + // should be able to safely treat output files as local to sources to + // better support gulp-sourcemaps. + + let base = sourceFiles.length === 1 && !config._emitWithoutBasePath ? host.getFile(sourceFiles[0].fileName).base : undefined; + let relative = base && path.relative(base, fileName); + let name = relative ? path.resolve(base, relative) : fileName; + let contents = new Buffer(text); + let vinyl = new Vinyl({ path: name, base, contents }); + if (/\.js$/.test(vinyl.path)) { + javaScriptFile = vinyl; + // gulp-sourcemaps will add an appropriate sourceMappingURL comment, so we need to remove the + // one that TypeScript generates. + const sourceMappingURLPattern = /(\r\n?|\n)?\/\/# sourceMappingURL=[^\r\n]+(?=[\r\n\s]*$)/; + const contents = javaScriptFile.contents.toString(); + javaScriptFile.contents = new Buffer(contents.replace(sourceMappingURLPattern, "")); + files.push(javaScriptFile); - // remeber the signature - if (value.signature && lastDtsHash[fileName] !== value.signature) { - lastDtsHash[fileName] = value.signature; - filesWithChangedSignature.push(fileName); - } - }); } - - // (2nd) check syntax - else if (toBeCheckedSyntactically.length) { - fileName = toBeCheckedSyntactically.pop(); - _log('[check syntax]', fileName); - promise = checkSyntaxSoon(fileName).then(diagnostics => { - delete oldErrors[fileName]; - if (diagnostics.length > 0) { - diagnostics.forEach(d => printDiagnostic(d, onError)); - newErrors[fileName] = diagnostics; - - // stop the world when there are syntax errors - toBeCheckedSyntactically.length = 0; - toBeCheckedSemantically.length = 0; - filesWithChangedSignature.length = 0; - } - }); + else if (/\.js\.map$/.test(vinyl.path)) { + sourceMapFile = vinyl; + } + else if (/\.d\.ts$/.test(vinyl.path)) { + files.push(vinyl); } + } + } - // (3rd) check semantics - else if (toBeCheckedSemantically.length) { + function setFileToCheck(file: ts.SourceFile, requiresToBeRoot: boolean) { + if (!requiresToBeRoot || rootFileNames.findIndex(fileName => fileName === file.fileName) !== -1) { + utils.maps.unorderedRemoveItem(rootFileNames, file.fileName); + toCheckSyntaxOf = toCheckSemanticOf = file; + return true; + } - fileName = toBeCheckedSemantically.pop(); - while (fileName && semanticCheckInfo.has(fileName)) { - fileName = toBeCheckedSemantically.pop(); - } + return false; + } - if (fileName) { - _log('[check semantics]', fileName); - promise = checkSemanticsSoon(fileName).then(diagnostics => { - delete oldErrors[fileName]; - semanticCheckInfo.set(fileName, diagnostics.length); - if (diagnostics.length > 0) { - diagnostics.forEach(d => printDiagnostic(d, onError)); - newErrors[fileName] = diagnostics; - } - }); - } - } + function getNextWork(workOnNext: () => void): Promise | undefined { + // If unrecoverable error, stop + if (unrecoverableError) { + _log('[Syntax errors]', '>>Stopping the error check and file emit<<') + return undefined; + } - // (4th) check dependents - else if (filesWithChangedSignature.length) { - while (filesWithChangedSignature.length) { - let fileName = filesWithChangedSignature.pop(); + // someone told us to stop this + if (tsToken.isCancellationRequested()) { + _log('[CANCEL]', '>>This compile run was cancelled<<') + return undefined; + } - if (!isExternalModule(service.getProgram().getSourceFile(fileName))) { - _log('[check semantics*]', fileName + ' is an internal module and it has changed shape -> check whatever hasn\'t been checked yet'); - toBeCheckedSemantically.push(...host.getScriptFileNames()); - filesWithChangedSignature.length = 0; - dependentFiles.length = 0; - break; - } + // SyntaxCheck + if (toCheckSyntaxOf) { + const file = toCheckSyntaxOf; + toCheckSyntaxOf = undefined; + _log('[check syntax]', file.fileName); + return createPromise(file, tsToken, getSyntacticDiagnostics, diagnostics => { + printDiagnostics(diagnostics, onError); + unrecoverableError = diagnostics.length > 0; + }, workOnNext); + } - host.collectDependents(fileName, dependentFiles); + // check semantics + if (toCheckSemanticOf) { + const file = toCheckSemanticOf; + toCheckSemanticOf = undefined; + _log('[check semantics]', file.fileName); + return createPromise(file, tsToken, getSemanticDiagnostics, diagnostics => printDiagnostics(diagnostics, onError), workOnNext); + } + + // If there are pending files to emit, emit next file + if (hasPendingEmit) { + return createPromise(/*arg*/ undefined, tsToken, emitNextAffectedFile, emitResult => { + if (!emitResult) { + // All emits complete, remove the toEmitFromBuilderState and + // set it as useOld + hasPendingEmit = false; + return; } - } - // (5th) dependents contd - else if (dependentFiles.length) { - fileName = dependentFiles.pop(); - while (fileName && seenAsDependentFile.has(fileName)) { - fileName = dependentFiles.pop(); + const { affected, diagnostics, files } = emitResult; + if (isAffectedProgram(affected)) { + // Whole program is changed, syntax check for all the files with requireAffectedFileToBeRoot setting + requireRootForOtherFiles = requireAffectedFileToBeRoot; } - if (fileName) { - seenAsDependentFile.add(fileName); - let value = semanticCheckInfo.get(fileName); - if (value === 0) { - // already validated successfully -> look at dependents next - host.collectDependents(fileName, dependentFiles); - - } else if (typeof value === 'undefined') { - // first validate -> look at dependents next - dependentFiles.push(fileName); - toBeCheckedSemantically.push(fileName); - } + else if(utils.maps.unorderedRemoveItem(sourceFilesToCheck, affected as ts.SourceFile)) { + // Set affected file to be checked for syntax and semantics + setFileToCheck(affected as ts.SourceFile, /*requiresToBeRoot*/ requireAffectedFileToBeRoot); } - } - // (last) done - else { - resolve(); - return; - } + printDiagnostics(diagnostics, onError); + for (const file of files) { + _log('[emit code]', file.path); + out(file); + } - if (!promise) { - promise = Promise.resolve(); - } + }, workOnNext); + } - promise.then(function () { - // change to change - process.nextTick(workOnNext); - }).catch(err => { - console.error(err); - }); + // Check remaining (non-affected files) + while (sourceFilesToCheck.length) { + const file = sourceFilesToCheck.pop(); + // Check only root file names - as thats what earlier happened + if (setFileToCheck(file, requireRootForOtherFiles)) { + return getNextWork(workOnNext); + } } - workOnNext(); + // Report global diagnostics + printDiagnostics(builderProgram.getOptionsDiagnostics(), onError); + printDiagnostics(builderProgram.getGlobalDiagnostics(), onError); - }).then(() => { - // store the build versions to not rebuilt the next time - newLastBuildVersion.forEach((value, key) => { - lastBuildVersion[key] = value; - }); + // Done + return undefined; + } + } +} - // print old errors and keep them - utils.collections.forEach(oldErrors, entry => { - entry.value.forEach(diag => printDiagnostic(diag, onError)); - newErrors[entry.key] = entry.value; - }); - oldErrors = newErrors; +function isAffectedProgram(affected: ts.SourceFile | ts.Program): affected is ts.Program { + return (affected as ts.SourceFile).kind !== ts.SyntaxKind.SourceFile +} - // print stats - if (config.verbose) { - var headNow = process.memoryUsage().heapUsed, - MB = 1024 * 1024; - log('[tsb]', - 'time:', colors.yellow((Date.now() - t1) + 'ms'), - 'mem:', colors.cyan(Math.ceil(headNow / MB) + 'MB'), colors.bgCyan('Δ' + Math.ceil((headNow - headUsed) / MB))); - headUsed = headNow; - } - }); - } +interface VinylFile { + file: Vinyl; + text: string; + mtime: Date; + name: string; +} +function getTextOfVinyl(file: Vinyl) { + return (file.contents).toString("utf8"); +} + +function createVinylFile(file: Vinyl): VinylFile { return { file, - build, - languageService: service + name: normalize(file.path), + text: getTextOfVinyl(file), + mtime: file.stat.mtime, }; } -class ScriptSnapshot implements ts.IScriptSnapshot { - private _file: Vinyl; - private _text: string; - private _mtime: Date; +interface Host extends ts.WatchCompilerHostOfFilesAndCompilerOptions { + addFile(file: Vinyl): boolean; + removeFile(filename: string): boolean; - constructor(file: Vinyl) { - this._file = file; - this._text = (file.contents).toString("utf8"); - this._mtime = file.stat.mtime; - } + getFile(filename: string): Vinyl; + getFileNames(): string[]; - public getVersion(): string { - return this._mtime.toUTCString(); - } + updateWithProgram(program: ts.EmitAndSemanticDiagnosticsBuilderProgram): void; +} - public getText(start: number, end: number): string { - return this._text.substring(start, end); - } +function createHost(options: ts.CompilerOptions, noFileSystemLookup: boolean): Host { + const watchedFiles = utils.maps.createMultiMap(); + const watchedDirectories = utils.maps.createMultiMap(); + const watchedDirectoriesRecursive = utils.maps.createMultiMap(); + const files = utils.maps.createMap(); + const useCaseSensitiveFileNames = ts.sys.useCaseSensitiveFileNames; + const getCanonicalFileName: (s: string) => string = useCaseSensitiveFileNames ? + ((fileName) => fileName) : + ((fileName) => fileName.toLowerCase()); - public getLength(): number { - return this._text.length; - } + const otherFiles = utils.maps.createMap(); - public getChangeRange(oldSnapshot: ts.IScriptSnapshot): ts.TextChangeRange { - return null; - } + return { + addFile, + removeFile, + getFile, + getFileNames, + updateWithProgram, + createHash: data => ts.sys.createHash(data), + + useCaseSensitiveFileNames: () => useCaseSensitiveFileNames, + getNewLine: () => ts.sys.newLine, + getCurrentDirectory, + getDefaultLibFileName, + fileExists, + readFile, + directoryExists, + getDirectories, + readDirectory, + realpath: resolvePath, + watchFile, + watchDirectory, + + createProgram: ts.createEmitAndSemanticDiagnosticsBuilderProgram, + + // To be filled in later + rootFiles: [], + options: undefined, + }; - public getFile(): Vinyl { - return this._file; + function toPath(filename: string) { + return resolvePath(getCanonicalFileName(normalize(filename))); + } + + function addFile(file: Vinyl) { + const filename = toPath(file.path); + const existingFile = files.get(filename); + if (existingFile) { + const mtime = file.stat.mtime; + if (existingFile.mtime !== mtime) { + existingFile.mtime = mtime; + const text = getTextOfVinyl(file); + if (file.text !== text) { + existingFile.text = text; + invokeFileWatcher(filename, ts.FileWatcherEventKind.Changed); + } + } + } + else { + otherFiles.delete(filename); + files.set(filename, createVinylFile(file)); + invokeFileWatcher(filename, ts.FileWatcherEventKind.Created); + invokeDirectoryWatcher(path.dirname(filename), filename); + return true; + } } - public getBase() { - return this._file.base; + function removeFile(filename: string) { + filename = toPath(filename); + if (files.has(filename)) { + files.delete(filename); + invokeFileWatcher(filename, ts.FileWatcherEventKind.Deleted); + invokeDirectoryWatcher(path.dirname(filename), filename); + return true; + } } -} -class LanguageServiceHost implements ts.LanguageServiceHost { - - private _settings: ts.CompilerOptions; - private _noFilesystemLookup: boolean; - private _snapshots: { [path: string]: ScriptSnapshot }; - private _projectVersion: number; - private _dependencies: utils.graph.Graph; - private _dependenciesRecomputeList: string[]; - private _fileNameToDeclaredModule: { [path: string]: string[] }; - - constructor(settings: ts.CompilerOptions, noFilesystemLookup: boolean) { - this._settings = settings; - this._noFilesystemLookup = noFilesystemLookup; - this._snapshots = Object.create(null); - this._projectVersion = 1; - this._dependencies = new utils.graph.Graph(s => s); - this._dependenciesRecomputeList = []; - this._fileNameToDeclaredModule = Object.create(null); + function getFile(filename: string) { + filename = toPath(filename); + const file = files.get(filename); + return file && file.file || otherFiles.get(filename); } - log(s: string): void { - // nothing + function getFileNames() { + const result: string[] = []; + files.forEach(file => { + result.push(file.name); + }); + return result; } - trace(s: string): void { - // nothing + function updateWithProgram(program: ts.EmitAndSemanticDiagnosticsBuilderProgram) { + otherFiles.forEach((file, filename) => { + if (!program.getSourceFile(file.path)) { + otherFiles.delete(filename); + } + }); } - error(s: string): void { - console.error(s); + function invokeWatcherCallbacks void>(callbacks: T[], fileName: string, eventKind?: ts.FileWatcherEventKind) { + if (callbacks) { + // The array copy is made to ensure that even if one of the callback removes the callbacks, + // we dont miss any callbacks following it + const cbs = callbacks.slice(); + for (const cb of cbs) { + cb(fileName, eventKind); + } + } } - getCompilationSettings(): ts.CompilerOptions { - return this._settings; + function invokeFileWatcher(fileName: string, eventKind: ts.FileWatcherEventKind) { + invokeWatcherCallbacks(watchedFiles.get(fileName), fileName, eventKind); } - getProjectVersion(): string { - return String(this._projectVersion); + function invokeDirectoryWatcher(directory: string, fileAddedOrRemoved: string) { + invokeWatcherCallbacks(watchedDirectories.get(directory), fileAddedOrRemoved); + invokeRecursiveDirectoryWatcher(directory, fileAddedOrRemoved); } - getScriptFileNames(): string[] { - const result: string[] = []; - const libLocation = this.getDefaultLibLocation(); - for (let fileName in this._snapshots) { - if (/\.tsx?/i.test(path.extname(fileName)) - && normalize(path.dirname(fileName)) !== libLocation) { - // only ts-files and not lib.d.ts-like files - result.push(fileName) - } + function invokeRecursiveDirectoryWatcher(directory: string, fileAddedOrRemoved: string) { + invokeWatcherCallbacks(watchedDirectoriesRecursive.get(directory), fileAddedOrRemoved); + const basePath = path.dirname(directory); + if (directory !== basePath) { + invokeRecursiveDirectoryWatcher(basePath, fileAddedOrRemoved); } - return result; } - getScriptVersion(filename: string): string { - filename = normalize(filename); - return this._snapshots[filename].getVersion(); - } - - getScriptSnapshot(filename: string): ScriptSnapshot { - filename = normalize(filename); - let result = this._snapshots[filename]; - if (!result && !this._noFilesystemLookup) { - try { - result = new ScriptSnapshot(new Vinyl({ - path: filename, - contents: readFileSync(filename), - base: this._settings.outDir, - stat: statSync(filename) - })); - this.addScriptSnapshot(filename, result); - } catch (e) { - // ignore - } + function readFile(path: string, encoding?: string) { + const canonicalName = toPath(path); + const file = files.get(canonicalName); + if (file) { + return file.text; } - return result; - } - - private static _declareModule = /declare\s+module\s+('|")(.+)\1/g; - - addScriptSnapshot(filename: string, snapshot: ScriptSnapshot): ScriptSnapshot { - this._projectVersion++; - filename = normalize(filename); - var old = this._snapshots[filename]; - if (!old || old.getVersion() !== snapshot.getVersion()) { - this._dependenciesRecomputeList.push(filename); - var node = this._dependencies.lookup(filename); - if (node) { - node.outgoing = Object.create(null); - } - - // (cheap) check for declare module - LanguageServiceHost._declareModule.lastIndex = 0; - let match: RegExpExecArray; - while ((match = LanguageServiceHost._declareModule.exec(snapshot.getText(0, snapshot.getLength())))) { - let declaredModules = this._fileNameToDeclaredModule[filename]; - if (!declaredModules) { - this._fileNameToDeclaredModule[filename] = declaredModules = []; - } - declaredModules.push(match[2]); - } + if (noFileSystemLookup) { + return undefined; + } + const text = ts.sys.readFile(path, encoding); + if (text !== undefined) { + otherFiles.set(canonicalName, new Vinyl({ + path, + contents: new Buffer(text), + base: options.outDir, + stat: statSync(path) + })); } - this._snapshots[filename] = snapshot; - return old; + return text; } - removeScriptSnapshot(filename: string): boolean { - this._projectVersion++; - filename = normalize(filename); - delete this._fileNameToDeclaredModule[filename]; - return delete this._snapshots[filename]; + function fileExists(path: string) { + return !!files.get(toPath(path)) || !noFileSystemLookup && ts.sys.fileExists(path); } - getLocalizedDiagnosticMessages(): any { - return null; - } + function directoryExists(dir: string) { + if (!noFileSystemLookup) { + return ts.sys.directoryExists(dir); + } - getCancellationToken(): ts.CancellationToken { - return { - isCancellationRequested: () => false, - throwIfCancellationRequested: (): void => { - // Do nothing.isCancellationRequested is always - // false so this method never throws - } - }; + dir = toPath(dir) + return utils.maps.forEachEntry(files, (_file, filename) => dir === path.dirname(filename)); } - getCurrentDirectory(): string { + function getCurrentDirectory() { return process.cwd(); } - fileExists(fileName: string): boolean { - return !this._noFilesystemLookup && existsSync(fileName); + function getDirectories(path: string): string[] { + return !noFileSystemLookup && ts.sys.getDirectories(path); } - readFile(fileName: string): string { - return this._noFilesystemLookup ? '' : readFileSync(fileName, 'utf8'); + function readDirectory(path: string, extensions?: ReadonlyArray, exclude?: ReadonlyArray, include?: ReadonlyArray, depth?: number): string[] { + return !noFileSystemLookup && ts.sys.readDirectory(path, extensions, exclude, include, depth); } - getDefaultLibFileName(options: ts.CompilerOptions): string { - return normalize(path.join(this.getDefaultLibLocation(), ts.getDefaultLibFileName(options))); + // NO fs watch + function createWatcher(path: string, map: utils.maps.MultiMap, callback: T): ts.FileWatcher { + path = toPath(path); + map.add(path, callback); + return { + close: () => { + map.remove(path, callback); + } + }; } - getDefaultLibLocation() { - let typescriptInstall = require.resolve('typescript'); - return normalize(path.dirname(typescriptInstall)); + function watchFile(path: string, callback: ts.FileWatcherCallback, pollingInterval?: number) { + return createWatcher(path, watchedFiles, callback); } - // ---- dependency management - - collectDependents(filename: string, target: string[]): void { - while (this._dependenciesRecomputeList.length) { - this._processFile(this._dependenciesRecomputeList.pop()); - } - filename = normalize(filename); - var node = this._dependencies.lookup(filename); - if (node) { - utils.collections.forEach(node.incoming, entry => target.push(entry.key)); - } + function watchDirectory(path: string, callback: ts.DirectoryWatcherCallback, recursive?: boolean) { + return createWatcher(path, recursive ? watchedDirectoriesRecursive : watchedDirectories, callback); } - _processFile(filename: string): void { - if (filename.match(/.*\.d\.ts$/)) { - return; - } - filename = normalize(filename); - var snapshot = this.getScriptSnapshot(filename), - info = ts.preProcessFile(snapshot.getText(0, snapshot.getLength()), true); - - // (1) ///-references - info.referencedFiles.forEach(ref => { - var resolvedPath = path.resolve(path.dirname(filename), ref.fileName), - normalizedPath = normalize(resolvedPath); - - this._dependencies.inertEdge(filename, normalizedPath); - }); - - // (2) import-require statements - info.importedFiles.forEach(ref => { - var stopDirname = normalize(this.getCurrentDirectory()), - dirname = filename, - found = false; - - while (!found && dirname.indexOf(stopDirname) === 0) { - dirname = path.dirname(dirname); - var resolvedPath = path.resolve(dirname, ref.fileName), - normalizedPath = normalize(resolvedPath); - - if (this.getScriptSnapshot(normalizedPath + '.ts')) { - this._dependencies.inertEdge(filename, normalizedPath + '.ts'); - found = true; - - } else if (this.getScriptSnapshot(normalizedPath + '.d.ts')) { - this._dependencies.inertEdge(filename, normalizedPath + '.d.ts'); - found = true; - } - } + function resolvePath(path: string) { + return !noFileSystemLookup ? ts.sys.resolvePath(path) : path; + } - if (!found) { - for (let key in this._fileNameToDeclaredModule) { - if (this._fileNameToDeclaredModule[key] && ~this._fileNameToDeclaredModule[key].indexOf(ref.fileName)) { - this._dependencies.inertEdge(filename, key); - } - } - } - }); + function getDefaultLibFileName(options: ts.CompilerOptions) { + const typescriptInstall = require.resolve('typescript'); + const basePathName = path.dirname(typescriptInstall); + const libFileName = ts.getDefaultLibFileName(options); + return normalize(path.join(basePathName, libFileName)); } -} +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5995aa9..432509b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,7 @@ export interface IncrementalCompiler { export function create(configOrName: { [option: string]: string | number | boolean; } | string, verbose?: boolean, json?: boolean, onError?: (message: any) => void): IncrementalCompiler { - let options = ts.getDefaultCompilerOptions(); + let options: ts.CompilerOptions; let config: builder.IConfiguration = { json, verbose, @@ -80,7 +80,7 @@ export function create(configOrName: { [option: string]: string | number | boole } let result = (token: builder.CancellationToken) => createStream(token); - Object.defineProperty(result, 'program', { get: () => _builder.languageService.getProgram() }); + Object.defineProperty(result, 'program', { get: () => _builder.getProgram() }); return result; } diff --git a/src/utils.ts b/src/utils.ts index 594091d..fa2551b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -155,4 +155,210 @@ export module graph { } } +} + +export module maps { + /** + * Type of objects whose values are all of the same type. + * The `in` and `for-in` operators can *not* be safely used, + * since `Object.prototype` may be modified by outside code. + */ + export interface MapLike { + [index: string]: T; + } + + /** ES6 Map interface, only read methods included. */ + export interface ReadonlyMap { + get(key: string): T | undefined; + has(key: string): boolean; + forEach(action: (value: T, key: string) => void): void; + readonly size: number; + keys(): Iterator; + values(): Iterator; + entries(): Iterator<[string, T]>; + } + + /** ES6 Map interface. */ + export interface Map extends ReadonlyMap { + set(key: string, value: T): this; + delete(key: string): boolean; + clear(): void; + } + + /** ES6 Iterator type. */ + export interface Iterator { + next(): { value: T, done: false } | { value: never, done: true }; + } + + // The global Map object. This may not be available, so we must test for it. + declare const Map: { new (): Map } | undefined; + // Internet Explorer's Map doesn't support iteration, so don't use it. + // tslint:disable-next-line:no-in-operator + const MapCtr = typeof Map !== "undefined" && "entries" in Map.prototype ? Map : shimMap(); + + // Keep the class inside a function so it doesn't get compiled if it's not used. + function shimMap(): { new (): Map } { + + class MapIterator { + private data: MapLike; + private keys: ReadonlyArray; + private index = 0; + private selector: (data: MapLike, key: string) => U; + constructor(data: MapLike, selector: (data: MapLike, key: string) => U) { + this.data = data; + this.selector = selector; + this.keys = Object.keys(data); + } + + public next(): { value: U, done: false } | { value: never, done: true } { + const index = this.index; + if (index < this.keys.length) { + this.index++; + return { value: this.selector(this.data, this.keys[index]), done: false }; + } + return { value: undefined as never, done: true }; + } + } + + return class implements Map { + private data = createDictionaryObject(); + public size = 0; + + get(key: string): T { + return this.data[key]; + } + + set(key: string, value: T): this { + if (!this.has(key)) { + this.size++; + } + this.data[key] = value; + return this; + } + + has(key: string): boolean { + // tslint:disable-next-line:no-in-operator + return key in this.data; + } + + delete(key: string): boolean { + if (this.has(key)) { + this.size--; + delete this.data[key]; + return true; + } + return false; + } + + clear(): void { + this.data = createDictionaryObject(); + this.size = 0; + } + + keys() { + return new MapIterator(this.data, (_data, key) => key); + } + + values() { + return new MapIterator(this.data, (data, key) => data[key]); + } + + entries() { + return new MapIterator(this.data, (data, key) => [key, data[key]] as [string, T]); + } + + forEach(action: (value: T, key: string) => void): void { + for (const key in this.data) { + action(this.data[key], key); + } + } + }; + } + + /** Create a MapLike with good performance. */ + function createDictionaryObject(): MapLike { + const map = Object.create(/*prototype*/ null); // tslint:disable-line:no-null-keyword + + // Using 'delete' on an object causes V8 to put the object in dictionary mode. + // This disables creation of hidden classes, which are expensive when an object is + // constantly changing shape. + map["__"] = undefined; + delete map["__"]; + + return map; + } + + /** Create a new map. If a template object is provided, the map will copy entries from it. */ + export function createMap(): Map { + return new MapCtr(); + } + + export interface MultiMap extends Map { + /** + * Adds the value to an array of values associated with the key, and returns the array. + * Creates the array if it does not already exist. + */ + add(key: string, value: T): T[]; + /** + * Removes a value from an array of values associated with the key. + * Does not preserve the order of those values. + * Does nothing if `key` is not in `map`, or `value` is not in `map[key]`. + */ + remove(key: string, value: T): void; + } + + export function createMultiMap(): MultiMap { + const map = createMap() as MultiMap; + map.add = multiMapAdd; + map.remove = multiMapRemove; + return map; + } + function multiMapAdd(this: MultiMap, key: string, value: T) { + let values = this.get(key); + if (values) { + values.push(value); + } + else { + this.set(key, values = [value]); + } + return values; + + } + function multiMapRemove(this: MultiMap, key: string, value: T) { + const values = this.get(key); + if (values) { + unorderedRemoveItem(values, value); + if (!values.length) { + this.delete(key); + } + } + } + + export function unorderedRemoveItem(array: T[], item: T): boolean { + for (let i = 0; i < array.length; i++) { + if (array[i] === item) { + // Fill in the "hole" left at `index`. + array[i] = array[array.length - 1]; + array.pop(); + return true; + } + } + return false; + } + + /** + * Calls `callback` for each entry in the map, returning the first truthy result. + * Use `map.forEach` instead for normal iteration. + */ + export function forEachEntry(map: ReadonlyMap, callback: (value: T, key: string) => U | undefined): U | undefined { + const iterator = map.entries(); + for (let { value: pair, done } = iterator.next(); !done; { value: pair, done } = iterator.next()) { + const [key, value] = pair; + const result = callback(value, key); + if (result) { + return result; + } + } + return undefined; + } } \ No newline at end of file