import { diffLines } from "diff"; import { join } from "path"; import { Recoverable, REPLServer, start } from "repl"; import { Register, register, TSError } from "ts-node"; import { Context, createContext } from "vm"; import { executeJavaScriptAsync, isRecoverable } from "./helpers"; interface ReplEvalResult { readonly result: any; readonly error: Error | null; } export class TsRepl { private readonly typeScriptService: Register; private readonly debuggingEnabled: boolean; private readonly evalFilename = `[eval].ts`; private readonly evalPath: string; private readonly evalData = { input: "", output: "" }; private readonly resetToZero: () => void; // Bookmark to empty TS input private readonly initialTypeScript: string; private context: Context | undefined; public constructor( tsconfigPath: string, initialTypeScript: string, debuggingEnabled = false, installationDir?: string, // required when the current working directory is not the installation path ) { this.typeScriptService = register({ project: tsconfigPath, ignoreDiagnostics: [ "1375", // TS1375: 'await' expressions are only allowed at the top level of a file when that file is a module, but this file has no imports or exports. Consider adding an empty 'export {}' to make this file a module. "1378", // TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to 'esnext' or 'system', and the 'target' option is set to 'es2017' or higher. ], }); this.debuggingEnabled = debuggingEnabled; this.resetToZero = this.appendTypeScriptInput(""); this.initialTypeScript = initialTypeScript; this.evalPath = join(installationDir || process.cwd(), this.evalFilename); } public async start(): Promise { /** * A wrapper around replEval used to match the method signature * for "Custom Evaluation Functions" * https://nodejs.org/api/repl.html#repl_custom_evaluation_functions */ const replEvalWrapper = async ( code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any, ): Promise => { const result = await this.replEval(code); callback(result.error, result.result); }; const repl = start({ prompt: ">> ", input: process.stdin, output: process.stdout, terminal: process.stdout.isTTY, eval: replEvalWrapper, useGlobal: false, }); // Prepare context for TypeScript: TypeScript compiler expects the exports shortcut // to exist in `Object.defineProperty(exports, "__esModule", { value: true });` const unsafeReplContext = repl.context as any; if (!unsafeReplContext.exports) { unsafeReplContext.exports = unsafeReplContext.module.exports; } // REPL context is created with a default set of module resolution paths, // like for example // [ '/home/me/repl/node_modules', // '/home/me/node_modules', // '/home/node_modules', // '/node_modules', // '/home/me/.node_modules', // '/home/me/.node_libraries', // '/usr/lib/nodejs' ] // However, this does not include the installation path of @cosmjs/cli because // REPL does not inherit module paths from the current process. Thus we override // the repl paths with the current process' paths unsafeReplContext.module.paths = module.paths; this.context = createContext(repl.context); const reset = async (): Promise => { this.resetToZero(); // Ensure code ends with "\n" due to implementation of replEval await this.compileAndExecute(this.initialTypeScript + "\n", false); }; await reset(); repl.on("reset", reset); repl.defineCommand("type", { help: "Check the type of a TypeScript identifier", action: (identifier: string) => { if (!identifier) { repl.displayPrompt(); return; } const identifierTypeScriptCode = `${identifier}\n`; const undo = this.appendTypeScriptInput(identifierTypeScriptCode); const identifierFirstPosition = this.evalData.input.length - identifierTypeScriptCode.length; const { name, comment } = this.typeScriptService.getTypeInfo( this.evalData.input, this.evalPath, identifierFirstPosition, ); undo(); repl.outputStream.write(`${name}\n${comment ? `${comment}\n` : ""}`); repl.displayPrompt(); }, }); return repl; } private async compileAndExecute(tsInput: string, isAutocompletionRequest: boolean): Promise { if (!isAutocompletionRequest) { // Expect POSIX lines (https://stackoverflow.com/a/729795) if (tsInput.length > 0 && !tsInput.endsWith("\n")) { throw new Error("final newline missing"); } } const undo = this.appendTypeScriptInput(tsInput); let output: string; try { // lineOffset unused at the moment (https://github.com/TypeStrong/ts-node/issues/661) output = this.typeScriptService.compile(this.evalData.input, this.evalPath); } catch (err) { undo(); throw err; } // Use `diff` to check for new JavaScript to execute. const changes = diffLines(this.evalData.output, output); if (isAutocompletionRequest) { undo(); } else { this.evalData.output = output; } // Execute new JavaScript. This may not necessarily be at the end only because e.g. an import // statement in TypeScript is compiled to no JavaScript until the imported symbol is used // somewhere. This btw. leads to a different execution order of imports than in the TS source. let lastResult: any; for (const added of changes.filter((change) => change.added)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion lastResult = await executeJavaScriptAsync(added.value, this.evalFilename, this.context!); } return lastResult; } /** * Add user-friendly error handling around compileAndExecute */ private async replEval(code: string): Promise { // TODO: Figure out how to handle completion here. if (code === ".scope") { return { result: undefined, error: null, }; } const isAutocompletionRequest = !/\n$/.test(code); try { const result = await this.compileAndExecute(code, isAutocompletionRequest); return { result: result, error: null, }; } catch (error: any) { if (this.debuggingEnabled) { console.info("Current REPL TypeScript program:"); console.info(this.evalData.input); } let outError: Error | null; if (error instanceof TSError) { // Support recoverable compilations using >= node 6. if (Recoverable && isRecoverable(error)) { outError = new Recoverable(error); } else { console.error(error.diagnosticText); outError = null; } } else { outError = error; } return { result: undefined, error: outError, }; } } private appendTypeScriptInput(input: string): () => void { const oldInput = this.evalData.input; const oldOutput = this.evalData.output; // Handle ASI issues with TypeScript re-evaluation. if (oldInput.charAt(oldInput.length - 1) === "\n" && /^\s*[[(`]/.test(input) && !/;\s*$/.test(oldInput)) { this.evalData.input = `${this.evalData.input.slice(0, -1)};\n`; } this.evalData.input += input; const undoFunction = (): void => { this.evalData.input = oldInput; this.evalData.output = oldOutput; }; return undoFunction; } }