cosmjs-util/packages/cli/src/tsrepl.ts
2021-11-24 13:26:36 +01:00

232 lines
7.6 KiB
TypeScript

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<REPLServer> {
/**
* 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<void> => {
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<void> => {
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<any> {
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<ReplEvalResult> {
// 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;
}
}