diff options
author | Ryan Dahl <ry@tinyclouds.org> | 2018-08-22 17:17:26 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-08-22 17:17:26 -0400 |
commit | e7cab715749e4c5000c24ffeade1ef915d15a68d (patch) | |
tree | 3ddb749342a415112f3bf4b1f3aa7156dca9c8af /js/compiler.ts | |
parent | c5bb412933d750c80c93eaa4840eb3bbac33bc2c (diff) |
runtime.ts refactor into compiler.ts (#564)
Adds compiler_test.ts
Diffstat (limited to 'js/compiler.ts')
-rw-r--r-- | js/compiler.ts | 595 |
1 files changed, 595 insertions, 0 deletions
diff --git a/js/compiler.ts b/js/compiler.ts new file mode 100644 index 000000000..84330490f --- /dev/null +++ b/js/compiler.ts @@ -0,0 +1,595 @@ +// Copyright 2018 the Deno authors. All rights reserved. MIT license. +import * as ts from "typescript"; +import { assetSourceCode } from "./assets"; +import * as deno from "./deno"; +import { libdeno, window, globalEval } from "./globals"; +import * as os from "./os"; +import { RawSourceMap } from "./types"; +import { assert, log, notImplemented } from "./util"; +import * as sourceMaps from "./v8_source_maps"; + +const EOL = "\n"; +const ASSETS = "$asset$"; + +// tslint:disable:no-any +type AmdCallback = (...args: any[]) => void; +type AmdErrback = (err: any) => void; +export type AmdFactory = (...args: any[]) => object | void; +// tslint:enable:no-any +export type AmdDefine = (deps: string[], factory: AmdFactory) => void; +type AmdRequire = ( + deps: string[], + callback: AmdCallback, + errback?: AmdErrback +) => void; + +// The location that a module is being loaded from. This could be a directory, +// like ".", or it could be a module specifier like +// "http://gist.github.com/somefile.ts" +type ContainingFile = string; +// The internal local filename of a compiled module. It will often be something +// like "/home/ry/.deno/gen/f7b4605dfbc4d3bb356e98fda6ceb1481e4a8df5.js" +type ModuleFileName = string; +// The external name of a module - could be a URL or could be a relative path. +// Examples "http://gist.github.com/somefile.ts" or "./somefile.ts" +type ModuleSpecifier = string; +// The compiled source code which is cached in .deno/gen/ +type OutputCode = string; + +/** + * Abstraction of the APIs required from the `os` module so they can be + * easily mocked. + */ +export interface Os { + codeCache: typeof os.codeCache; + codeFetch: typeof os.codeFetch; + exit: typeof os.exit; +} + +/** + * Abstraction of the APIs required from the `typescript` module so they can + * be easily mocked. + */ +export interface Ts { + createLanguageService: typeof ts.createLanguageService; + /* tslint:disable-next-line:max-line-length */ + formatDiagnosticsWithColorAndContext: typeof ts.formatDiagnosticsWithColorAndContext; +} + +/** + * A simple object structure for caching resolved modules and their contents. + * + * Named `ModuleMetaData` to clarify it is just a representation of meta data of + * the module, not the actual module instance. + */ +export class ModuleMetaData { + public readonly exports = {}; + public scriptSnapshot?: ts.IScriptSnapshot; + public scriptVersion = ""; + + constructor( + public readonly fileName: string, + public readonly sourceCode = "", + public outputCode = "" + ) { + if (outputCode !== "" || fileName.endsWith(".d.ts")) { + this.scriptVersion = "1"; + } + } +} + +/** + * The required minimal API to allow formatting of TypeScript compiler + * diagnostics. + */ +const formatDiagnosticsHost: ts.FormatDiagnosticsHost = { + getCurrentDirectory: () => ".", + getCanonicalFileName: (fileName: string) => fileName, + getNewLine: () => EOL +}; + +/** + * Throw a module resolution error, when a module is unsuccessfully resolved. + */ +function throwResolutionError( + message: string, + moduleSpecifier: ModuleSpecifier, + containingFile: ContainingFile +): never { + throw new Error( + // tslint:disable-next-line:max-line-length + `Cannot resolve module "${moduleSpecifier}" from "${containingFile}".\n ${message}` + ); +} + +// ts.ScriptKind is not available at runtime, so local enum definition +enum ScriptKind { + JS = 1, + TS = 3, + JSON = 6 +} + +/** + * A singleton class that combines the TypeScript Language Service host API + * with Deno specific APIs to provide an interface for compiling and running + * TypeScript and JavaScript modules. + */ +export class DenoCompiler implements ts.LanguageServiceHost { + // Modules are usually referenced by their ModuleSpecifier and ContainingFile, + // and keeping a map of the resolved module file name allows more efficient + // future resolution + private readonly _fileNamesMap = new Map< + ContainingFile, + Map<ModuleSpecifier, ModuleFileName> + >(); + // A reference to global eval, so it can be monkey patched during testing + private _globalEval = globalEval; + // A reference to the log utility, so it can be monkey patched during testing + private _log = log; + // A map of module file names to module meta data + private readonly _moduleMetaDataMap = new Map< + ModuleFileName, + ModuleMetaData + >(); + // TODO ideally this are not static and can be influenced by command line + // arguments + private readonly _options: Readonly<ts.CompilerOptions> = { + allowJs: true, + module: ts.ModuleKind.AMD, + outDir: "$deno$", + // TODO https://github.com/denoland/deno/issues/23 + inlineSourceMap: true, + inlineSources: true, + stripComments: true, + target: ts.ScriptTarget.ESNext + }; + // A reference to the `./os.ts` module, so it can be monkey patched during + // testing + private _os: Os = os; + // Used to contain the script file we are currently running + private _scriptFileNames: string[] = []; + // A reference to the TypeScript LanguageService instance so it can be + // monkey patched during testing + private _service: ts.LanguageService; + // A reference to `typescript` module so it can be monkey patched during + // testing + private _ts: Ts = ts; + // A reference to the global scope so it can be monkey patched during + // testing + private _window = window; + + /** + * The TypeScript language service often refers to the resolved fileName of + * a module, this is a shortcut to avoid unnecessary module resolution logic + * for modules that may have been initially resolved by a `moduleSpecifier` + * and `containingFile`. Also, `resolveModule()` throws when the module + * cannot be resolved, which isn't always valid when dealing with the + * TypeScript compiler, but the TypeScript compiler shouldn't be asking about + * external modules that we haven't told it about yet. + */ + private _getModuleMetaData( + fileName: ModuleFileName + ): ModuleMetaData | undefined { + return this._moduleMetaDataMap.has(fileName) + ? this._moduleMetaDataMap.get(fileName) + : fileName.startsWith(ASSETS) + ? this.resolveModule(fileName, "") + : undefined; + } + + /** + * Setup being able to map back source references back to their source + * + * TODO is this the best place for this? It is tightly coupled to how the + * compiler works, but it is also tightly coupled to how the whole runtime + * environment is bootstrapped. It also needs efficient access to the + * `outputCode` of the module information, which exists inside of the + * compiler instance. + */ + private _setupSourceMaps(): void { + sourceMaps.install({ + installPrepareStackTrace: true, + getGeneratedContents: (fileName: string): string | RawSourceMap => { + this._log("getGeneratedContents", fileName); + if (fileName === "gen/bundle/main.js") { + assert(libdeno.mainSource.length > 0); + return libdeno.mainSource; + } else if (fileName === "main.js.map") { + return libdeno.mainSourceMap; + } else if (fileName === "deno_main.js") { + return ""; + } else { + const moduleMetaData = this._moduleMetaDataMap.get(fileName); + if (!moduleMetaData) { + this._log("getGeneratedContents cannot find", fileName); + return ""; + } + return moduleMetaData.outputCode; + } + } + }); + } + + private constructor() { + if (DenoCompiler._instance) { + throw new TypeError("Attempt to create an additional compiler."); + } + this._service = this._ts.createLanguageService(this); + this._setupSourceMaps(); + } + + // Deno specific compiler API + + /** + * Retrieve the output of the TypeScript compiler for a given `fileName`. + */ + compile(fileName: ModuleFileName): OutputCode { + const service = this._service; + const output = service.getEmitOutput(fileName); + + // Get the relevant diagnostics - this is 3x faster than + // `getPreEmitDiagnostics`. + const diagnostics = [ + ...service.getCompilerOptionsDiagnostics(), + ...service.getSyntacticDiagnostics(fileName), + ...service.getSemanticDiagnostics(fileName) + ]; + if (diagnostics.length > 0) { + const errMsg = this._ts.formatDiagnosticsWithColorAndContext( + diagnostics, + formatDiagnosticsHost + ); + console.log(errMsg); + // All TypeScript errors are terminal for deno + this._os.exit(1); + } + + assert(!output.emitSkipped, "The emit was skipped for an unknown reason."); + + // Currently we are inlining source maps, there should be only 1 output file + // See: https://github.com/denoland/deno/issues/23 + assert( + output.outputFiles.length === 1, + "Only single file should be output." + ); + + const [outputFile] = output.outputFiles; + return outputFile.text; + } + + /** + * Create a localized AMD `define` function and return it. + */ + makeDefine(moduleMetaData: ModuleMetaData): AmdDefine { + const localDefine = (deps: string[], factory: AmdFactory): void => { + // TypeScript will emit a local require dependency when doing dynamic + // `import()` + const localRequire: AmdRequire = ( + deps: string[], + callback: AmdCallback, + errback?: AmdErrback + ): void => { + this._log("localRequire", deps); + try { + const args = deps.map(dep => { + if (dep in DenoCompiler._builtins) { + return DenoCompiler._builtins[dep]; + } else { + const depModuleMetaData = this.run(dep, moduleMetaData.fileName); + return depModuleMetaData.exports; + } + }); + callback(...args); + } catch (e) { + if (errback) { + errback(e); + } else { + throw e; + } + } + }; + const localExports = moduleMetaData.exports; + this._log("localDefine", moduleMetaData.fileName, deps, localExports); + const args = deps.map(dep => { + if (dep === "require") { + return localRequire; + } else if (dep === "exports") { + return localExports; + } else if (dep in DenoCompiler._builtins) { + return DenoCompiler._builtins[dep]; + } else { + const depModuleMetaData = this.run(dep, moduleMetaData.fileName); + return depModuleMetaData.exports; + } + }); + factory(...args); + }; + return localDefine; + } + + /** + * Given a `moduleSpecifier` and `containingFile` retrieve the cached + * `fileName` for a given module. If the module has yet to be resolved + * this will return `undefined`. + */ + resolveFileName( + moduleSpecifier: ModuleSpecifier, + containingFile: ContainingFile + ): ModuleFileName | undefined { + this._log("resolveFileName", { moduleSpecifier, containingFile }); + const innerMap = this._fileNamesMap.get(containingFile); + if (innerMap) { + return innerMap.get(moduleSpecifier); + } + return undefined; + } + + /** + * Given a `moduleSpecifier` and `containingFile`, resolve the module and + * return the `ModuleMetaData`. + */ + resolveModule( + moduleSpecifier: ModuleSpecifier, + containingFile: ContainingFile + ): ModuleMetaData { + this._log("resolveModule", { moduleSpecifier, containingFile }); + assert(moduleSpecifier != null && moduleSpecifier.length > 0); + let fileName = this.resolveFileName(moduleSpecifier, containingFile); + if (fileName && this._moduleMetaDataMap.has(fileName)) { + return this._moduleMetaDataMap.get(fileName)!; + } + let sourceCode: string | undefined; + let outputCode: string | undefined; + if ( + moduleSpecifier.startsWith(ASSETS) || + containingFile.startsWith(ASSETS) + ) { + // Assets are compiled into the runtime javascript bundle. + // we _know_ `.pop()` will return a string, but TypeScript doesn't so + // not null assertion + const moduleId = moduleSpecifier.split("/").pop()!; + const assetName = moduleId.includes(".") ? moduleId : `${moduleId}.d.ts`; + assert(assetName in assetSourceCode, `No such asset "${assetName}"`); + sourceCode = assetSourceCode[assetName]; + fileName = `${ASSETS}/${assetName}`; + } else { + // We query Rust with a CodeFetch message. It will load the sourceCode, + // and if there is any outputCode cached, will return that as well. + let fetchResponse; + try { + fetchResponse = this._os.codeFetch(moduleSpecifier, containingFile); + } catch (e) { + return throwResolutionError( + `os.codeFetch message: ${e.message}`, + moduleSpecifier, + containingFile + ); + } + fileName = fetchResponse.filename || undefined; + sourceCode = fetchResponse.sourceCode || undefined; + outputCode = fetchResponse.outputCode || undefined; + } + if (!sourceCode || sourceCode.length === 0 || !fileName) { + return throwResolutionError( + "Invalid source code or file name.", + moduleSpecifier, + containingFile + ); + } + this._log("resolveModule sourceCode length ", sourceCode.length); + this.setFileName(moduleSpecifier, containingFile, fileName); + if (fileName && this._moduleMetaDataMap.has(fileName)) { + return this._moduleMetaDataMap.get(fileName)!; + } + const moduleMetaData = new ModuleMetaData(fileName, sourceCode, outputCode); + this._moduleMetaDataMap.set(fileName, moduleMetaData); + return moduleMetaData; + } + + /** + * Resolve the `fileName` for a given `moduleSpecifier` and `containingFile` + */ + resolveModuleName( + moduleSpecifier: ModuleSpecifier, + containingFile: ContainingFile + ): ModuleFileName | undefined { + const moduleMetaData = this.resolveModule(moduleSpecifier, containingFile); + return moduleMetaData ? moduleMetaData.fileName : undefined; + } + + /* tslint:disable-next-line:no-any */ + /** + * Execute a module based on the `moduleSpecifier` and the `containingFile` + * and return the resulting `FileModule`. + */ + run( + moduleSpecifier: ModuleSpecifier, + containingFile: ContainingFile + ): ModuleMetaData { + this._log("run", { moduleSpecifier, containingFile }); + const moduleMetaData = this.resolveModule(moduleSpecifier, containingFile); + const fileName = moduleMetaData.fileName; + this._scriptFileNames = [fileName]; + const sourceCode = moduleMetaData.sourceCode; + let outputCode = moduleMetaData.outputCode; + if (!outputCode) { + outputCode = moduleMetaData.outputCode = `${this.compile( + fileName + )}\n//# sourceURL=${fileName}`; + moduleMetaData!.scriptVersion = "1"; + this._os.codeCache(fileName, sourceCode, outputCode); + } + this._window.define = this.makeDefine(moduleMetaData); + this._globalEval(moduleMetaData.outputCode); + this._window.define = undefined; + return moduleMetaData!; + } + + /** + * Caches the resolved `fileName` in relationship to the `moduleSpecifier` + * and `containingFile` in order to reduce calls to the privileged side + * to retrieve the contents of a module. + */ + setFileName( + moduleSpecifier: ModuleSpecifier, + containingFile: ContainingFile, + fileName: ModuleFileName + ): void { + this._log("setFileName", { moduleSpecifier, containingFile }); + let innerMap = this._fileNamesMap.get(containingFile); + if (!innerMap) { + innerMap = new Map(); + this._fileNamesMap.set(containingFile, innerMap); + } + innerMap.set(moduleSpecifier, fileName); + } + + // TypeScript Language Service API + + getCompilationSettings(): ts.CompilerOptions { + this._log("getCompilationSettings()"); + return this._options; + } + + getNewLine(): string { + return EOL; + } + + getScriptFileNames(): string[] { + // This is equal to `"files"` in the `tsconfig.json`, therefore we only need + // to include the actual base source files we are evaluating at the moment, + // which would be what is set during the `.run()` + return this._scriptFileNames; + } + + getScriptKind(fileName: ModuleFileName): ts.ScriptKind { + this._log("getScriptKind()", fileName); + const suffix = fileName.substr(fileName.lastIndexOf(".") + 1); + switch (suffix) { + case "ts": + return ScriptKind.TS; + case "js": + return ScriptKind.JS; + case "json": + return ScriptKind.JSON; + default: + return this._options.allowJs ? ScriptKind.JS : ScriptKind.TS; + } + } + + getScriptVersion(fileName: ModuleFileName): string { + this._log("getScriptVersion()", fileName); + const moduleMetaData = this._getModuleMetaData(fileName); + return (moduleMetaData && moduleMetaData.scriptVersion) || ""; + } + + getScriptSnapshot(fileName: ModuleFileName): ts.IScriptSnapshot | undefined { + this._log("getScriptSnapshot()", fileName); + const moduleMetaData = this._getModuleMetaData(fileName); + if (moduleMetaData) { + return ( + moduleMetaData.scriptSnapshot || + (moduleMetaData.scriptSnapshot = { + getText(start, end) { + return moduleMetaData.sourceCode.substring(start, end); + }, + getLength() { + return moduleMetaData.sourceCode.length; + }, + getChangeRange() { + return undefined; + } + }) + ); + } else { + return undefined; + } + } + + getCurrentDirectory(): string { + this._log("getCurrentDirectory()"); + return ""; + } + + getDefaultLibFileName(): string { + this._log("getDefaultLibFileName()"); + const moduleSpecifier = "lib.globals.d.ts"; + const moduleMetaData = this.resolveModule(moduleSpecifier, ASSETS); + return moduleMetaData.fileName; + } + + useCaseSensitiveFileNames(): boolean { + this._log("useCaseSensitiveFileNames"); + return true; + } + + readFile(path: string): string | undefined { + this._log("readFile", path); + return notImplemented(); + } + + fileExists(fileName: string): boolean { + const moduleMetaData = this._getModuleMetaData(fileName); + const exists = moduleMetaData != null; + this._log("fileExists", fileName, exists); + return exists; + } + + resolveModuleNames( + moduleNames: ModuleSpecifier[], + containingFile: ContainingFile + ): ts.ResolvedModule[] { + this._log("resolveModuleNames", { moduleNames, containingFile }); + return moduleNames.map(name => { + let resolvedFileName; + if (name === "deno") { + resolvedFileName = this.resolveModuleName("deno.d.ts", ASSETS); + } else if (name === "compiler") { + resolvedFileName = this.resolveModuleName("compiler.d.ts", ASSETS); + } else if (name === "typescript") { + resolvedFileName = this.resolveModuleName("typescript.d.ts", ASSETS); + } else { + resolvedFileName = this.resolveModuleName(name, containingFile); + } + // According to the interface we shouldn't return `undefined` but if we + // fail to return the same length of modules to those we cannot resolve + // then TypeScript fails on an assertion that the lengths can't be + // different, so we have to return an "empty" resolved module + // TODO: all this does is push the problem downstream, and TypeScript + // will complain it can't identify the type of the file and throw + // a runtime exception, so we need to handle missing modules better + resolvedFileName = resolvedFileName || ""; + // This flags to the compiler to not go looking to transpile functional + // code, anything that is in `/$asset$/` is just library code + const isExternalLibraryImport = resolvedFileName.startsWith(ASSETS); + // TODO: we should be returning a ts.ResolveModuleFull + return { resolvedFileName, isExternalLibraryImport }; + }); + } + + // Deno specific static properties and methods + + /** + * Built in modules which can be returned to external modules + * + * Placed as a private static otherwise we get use before + * declared with the `DenoCompiler` + */ + // tslint:disable-next-line:no-any + private static _builtins: { [mid: string]: any } = { + typescript: ts, + deno, + compiler: { DenoCompiler, ModuleMetaData } + }; + + private static _instance: DenoCompiler | undefined; + + /** + * Returns the instance of `DenoCompiler` or creates a new instance. + */ + static instance(): DenoCompiler { + return ( + DenoCompiler._instance || (DenoCompiler._instance = new DenoCompiler()) + ); + } +} |