From ffe2428980ecf0b4be31d4fbc9e7613d0da36bad Mon Sep 17 00:00:00 2001 From: MarcManiez Date: Wed, 2 Sep 2020 20:47:38 -0700 Subject: [PATCH 01/18] Add createReplEval function --- src/bin.ts | 165 +++++---------------------------------------------- src/index.ts | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 151 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 13c19c38a..9237e700e 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,32 +1,25 @@ #!/usr/bin/env node import { join, resolve, dirname } from 'path' -import { start, Recoverable } from 'repl' +import { start } from 'repl' import { inspect } from 'util' import Module = require('module') import arg = require('arg') -import { diffLines } from 'diff' -import { Script } from 'vm' import { readFileSync, statSync, realpathSync } from 'fs' import { homedir } from 'os' -import { VERSION, TSError, parse, Register, register } from './index' - -/** - * Eval filename for REPL/debug. - */ -const EVAL_FILENAME = `[eval].ts` - -/** - * Eval state management. - */ -class EvalState { - input = '' - output = '' - version = 0 - lines = 0 - - constructor (public path: string) {} -} +import { + _eval, + appendEval, + createReplEval, + EVAL_FILENAME, + EvalState, + exec, + parse, + Register, + register, + TSError, + VERSION +} from './index' /** * Main `bin` functionality. @@ -306,45 +299,6 @@ function evalAndExit (service: Register, state: EvalState, module: Module, code: } } -/** - * Evaluate the code snippet. - */ -function _eval (service: Register, state: EvalState, input: string) { - const lines = state.lines - const isCompletion = !/\n$/.test(input) - const undo = appendEval(state, input) - let output: string - - try { - output = service.compile(state.input, state.path, -lines) - } catch (err) { - undo() - throw err - } - - // Use `diff` to check for new JavaScript to execute. - const changes = diffLines(state.output, output) - - if (isCompletion) { - undo() - } else { - state.output = output - } - - return changes.reduce((result, change) => { - return change.added ? exec(change.value, state.path) : result - }, undefined) -} - -/** - * Execute some code. - */ -function exec (code: string, filename: string) { - const script = new Script(code, { filename: filename }) - - return script.runInThisContext() -} - /** * Start a CLI REPL. */ @@ -365,41 +319,10 @@ function startRepl (service: Register, state: EvalState, code?: string) { output: process.stdout, // Mimicking node's REPL implementation: https://github.com/nodejs/node/blob/168b22ba073ee1cbf8d0bcb4ded7ff3099335d04/lib/internal/repl.js#L28-L30 terminal: process.stdout.isTTY && !parseInt(process.env.NODE_NO_READLINE!, 10), - eval: replEval, + eval: createReplEval(service, state), useGlobal: true }) - /** - * Eval code from the REPL. - */ - function replEval (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any) { - let err: Error | null = null - let result: any - - // TODO: Figure out how to handle completion here. - if (code === '.scope') { - callback(err) - return - } - - try { - result = _eval(service, state, code) - } catch (error) { - if (error instanceof TSError) { - // Support recoverable compilations using >= node 6. - if (Recoverable && isRecoverable(error)) { - err = new Recoverable(error) - } else { - console.error(error) - } - } else { - err = error - } - } - - return callback(err, result) - } - // Bookmark the point where we should reset the REPL state. const resetEval = appendEval(state, '') @@ -445,64 +368,6 @@ function startRepl (service: Register, state: EvalState, code?: string) { } } -/** - * Append to the eval instance and return an undo function. - */ -function appendEval (state: EvalState, input: string) { - const undoInput = state.input - const undoVersion = state.version - const undoOutput = state.output - const undoLines = state.lines - - // Handle ASI issues with TypeScript re-evaluation. - if (undoInput.charAt(undoInput.length - 1) === '\n' && /^\s*[\/\[(`-]/.test(input) && !/;\s*$/.test(undoInput)) { - state.input = `${state.input.slice(0, -1)};\n` - } - - state.input += input - state.lines += lineCount(input) - state.version++ - - return function () { - state.input = undoInput - state.output = undoOutput - state.version = undoVersion - state.lines = undoLines - } -} - -/** - * Count the number of lines. - */ -function lineCount (value: string) { - let count = 0 - - for (const char of value) { - if (char === '\n') { - count++ - } - } - - return count -} - -const RECOVERY_CODES: Set = new Set([ - 1003, // "Identifier expected." - 1005, // "')' expected." - 1109, // "Expression expected." - 1126, // "Unexpected end of text." - 1160, // "Unterminated template literal." - 1161, // "Unterminated regular expression literal." - 2355 // "A function whose declared type is neither 'void' nor 'any' must return a value." -]) - -/** - * Check if a function can recover gracefully. - */ -function isRecoverable (error: TSError) { - return error.diagnosticCodes.every(code => RECOVERY_CODES.has(code)) -} - /** Safe `hasOwnProperty` */ function hasOwnProperty (object: any, property: string): boolean { return Object.prototype.hasOwnProperty.call(object, property) diff --git a/src/index.ts b/src/index.ts index e2b759afc..9d8252c2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,8 @@ -import { relative, basename, extname, resolve, dirname, join, isAbsolute } from 'path' +import { diffLines } from 'diff' +import { relative, basename, extname, resolve, dirname, join } from 'path' +import { Recoverable } from 'repl' import sourceMapSupport = require('source-map-support') +import { Script } from 'vm' import * as ynModule from 'yn' import { BaseError } from 'make-error' import * as util from 'util' @@ -42,6 +45,12 @@ declare global { } } +/** + * @internal + * Eval filename for REPL/debug. + */ +export const EVAL_FILENAME = `[eval].ts` + /** * @internal */ @@ -974,6 +983,160 @@ export function create (rawOptions: CreateOptions = {}): Register { return { ts, config, compile, getTypeInfo, ignored, enabled, options } } +/** + * Eval state management. + */ +export class EvalState { + input = '' + output = '' + version = 0 + lines = 0 + + constructor (public path: string) {} +} + +/** + * @param service - TypeScript compiler instance + * @param state - Eval state management + * + * @returns an evaluator for the node REPL + */ +export function createReplEval ( + service: Register, + state: EvalState = new EvalState(join(process.cwd(), EVAL_FILENAME)) +) { + /** + * Eval code from the REPL. + */ + return function (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any) { + let err: Error | null = null + let result: any + + // TODO: Figure out how to handle completion here. + if (code === '.scope') { + callback(err) + return + } + + try { + result = _eval(service, state, code) + } catch (error) { + if (error instanceof TSError) { + // Support recoverable compilations using >= node 6. + if (Recoverable && isRecoverable(error)) { + err = new Recoverable(error) + } else { + console.error(error) + } + } else { + err = error + } + } + + return callback(err, result) + } +} + +/** + * @internal + * Append to the eval instance and return an undo function. + */ +export function appendEval (state: EvalState, input: string) { + const undoInput = state.input + const undoVersion = state.version + const undoOutput = state.output + const undoLines = state.lines + + // Handle ASI issues with TypeScript re-evaluation. + if (undoInput.charAt(undoInput.length - 1) === '\n' && /^\s*[\/\[(`-]/.test(input) && !/;\s*$/.test(undoInput)) { + state.input = `${state.input.slice(0, -1)};\n` + } + + state.input += input + state.lines += lineCount(input) + state.version++ + + return function () { + state.input = undoInput + state.output = undoOutput + state.version = undoVersion + state.lines = undoLines + } +} + +/** + * Count the number of lines. + */ +function lineCount (value: string) { + let count = 0 + + for (const char of value) { + if (char === '\n') { + count++ + } + } + + return count +} + +/** + * @internal + * Evaluate the code snippet. + */ +export function _eval (service: Register, state: EvalState, input: string) { + const lines = state.lines + const isCompletion = !/\n$/.test(input) + const undo = appendEval(state, input) + let output: string + + try { + output = service.compile(state.input, state.path, -lines) + } catch (err) { + undo() + throw err + } + + // Use `diff` to check for new JavaScript to execute. + const changes = diffLines(state.output, output) + + if (isCompletion) { + undo() + } else { + state.output = output + } + + return changes.reduce((result, change) => { + return change.added ? exec(change.value, state.path) : result + }, undefined) +} + +/** + * @internal + * Execute some code. + */ +export function exec (code: string, filename: string) { + const script = new Script(code, { filename: filename }) + + return script.runInThisContext() +} + +const RECOVERY_CODES: Set = new Set([ + 1003, // "Identifier expected." + 1005, // "')' expected." + 1109, // "Expression expected." + 1126, // "Unexpected end of text." + 1160, // "Unterminated template literal." + 1161, // "Unterminated regular expression literal." + 2355 // "A function whose declared type is neither 'void' nor 'any' must return a value." +]) + +/** + * Check if a function can recover gracefully. + */ +function isRecoverable (error: TSError) { + return error.diagnosticCodes.every(code => RECOVERY_CODES.has(code)) +} + /** * Check if the filename should be ignored. */ From 6c3ecbf6926610f70f14673a5f9e80aa91493409 Mon Sep 17 00:00:00 2001 From: MarcManiez Date: Wed, 2 Sep 2020 20:57:41 -0700 Subject: [PATCH 02/18] Add a test --- src/index.spec.ts | 9 +++++++++ tests/repl.ts | 14 ++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/repl.ts diff --git a/src/index.spec.ts b/src/index.spec.ts index 10e6aa376..b16ca9707 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -370,6 +370,15 @@ describe('ts-node', function () { cp.stdin!.end('\nconst a = 123\n.type a') }) + it('should run a custom REPL with with the ts-node repl evaluator', function (done) { + exec(`${BIN_PATH} ./tests/repl.ts`, function (err, stdout) { + expect(err && err.signal).to.equal('SIGTERM') + expect(stdout).to.equal('> ') + + return done() + }) + }) + it('should support require flags', function (done) { exec(`${cmd} -r ./tests/hello-world -pe "console.log('success')"`, function (err, stdout) { expect(err).to.equal(null) diff --git a/tests/repl.ts b/tests/repl.ts new file mode 100644 index 000000000..b88ede329 --- /dev/null +++ b/tests/repl.ts @@ -0,0 +1,14 @@ +import { start } from 'repl' +import { createReplEval, register } from '../src/index' + +const service = register() +start({ + prompt: '> ', + input: process.stdin, + output: process.stdout, + terminal: process.stdout.isTTY && !parseInt(process.env.NODE_NO_READLINE!, 10), + eval: createReplEval(service), + useGlobal: true +}) + +process.emit('SIGTERM', 'SIGTERM') From c064d710a476bbdbe639e0242402310e539fbe30 Mon Sep 17 00:00:00 2001 From: MarcManiez Date: Wed, 2 Sep 2020 20:58:52 -0700 Subject: [PATCH 03/18] Fix a couple of typos --- README.md | 2 +- src/index.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1c4f7f990..adb51ddb4 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ _The name of the environment variable and the option's default value are denoted ### Programmatic-only Options -* `transformers` `_ts.CustomTransformers | ((p: _ts.Program) => _ts.CustomTransformers)`: An object with transformers or a function that accepts a program and returns an transformers object to pass to TypeScript. Function isn't available with `transpileOnly` flag +* `transformers` `_ts.CustomTransformers | ((p: _ts.Program) => _ts.CustomTransformers)`: An object with transformers or a function that accepts a program and returns a transformers object to pass to TypeScript. Function isn't available with `transpileOnly` flag * `readFile`: Custom TypeScript-compatible file reading function * `fileExists`: Custom TypeScript-compatible file existence function diff --git a/src/index.spec.ts b/src/index.spec.ts index b16ca9707..182a4662b 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -353,8 +353,8 @@ describe('ts-node', function () { }) cp.stdin!.end('console.log("123")\n') - }) + it('REPL has command to get type information', function (done) { const cp = exec(`${cmd} --interactive`, function (err, stdout) { expect(err).to.equal(null) From b6eca72af6359c6bef0cfa06ca991863cef053c6 Mon Sep 17 00:00:00 2001 From: MarcManiez Date: Thu, 3 Sep 2020 15:18:12 -0700 Subject: [PATCH 04/18] Actually test the evaluator --- src/index.spec.ts | 10 +++++++--- tests/repl.ts | 2 -- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 182a4662b..59712c657 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -371,12 +371,16 @@ describe('ts-node', function () { }) it('should run a custom REPL with with the ts-node repl evaluator', function (done) { - exec(`${BIN_PATH} ./tests/repl.ts`, function (err, stdout) { - expect(err && err.signal).to.equal('SIGTERM') - expect(stdout).to.equal('> ') + const cp = exec(`${BIN_PATH} ./tests/repl.ts`, function (err, stdout) { + expect(err).to.equal(null) + expect(stdout).to.equal( + '> \'use strict\'\n' + + '> ' + ) return done() }) + cp.stdin!.end('const letsWrite: string = "some typescript!"\n') }) it('should support require flags', function (done) { diff --git a/tests/repl.ts b/tests/repl.ts index b88ede329..d24cde4b4 100644 --- a/tests/repl.ts +++ b/tests/repl.ts @@ -10,5 +10,3 @@ start({ eval: createReplEval(service), useGlobal: true }) - -process.emit('SIGTERM', 'SIGTERM') From ada5afd2920d5bb854ce3dba966c4e0e746e2f3b Mon Sep 17 00:00:00 2001 From: MarcManiez Date: Fri, 4 Sep 2020 09:03:36 -0700 Subject: [PATCH 05/18] Try removing spec --- src/index.spec.ts | 13 ------------- tests/repl.ts | 12 ------------ 2 files changed, 25 deletions(-) delete mode 100644 tests/repl.ts diff --git a/src/index.spec.ts b/src/index.spec.ts index 59712c657..326273209 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -370,19 +370,6 @@ describe('ts-node', function () { cp.stdin!.end('\nconst a = 123\n.type a') }) - it('should run a custom REPL with with the ts-node repl evaluator', function (done) { - const cp = exec(`${BIN_PATH} ./tests/repl.ts`, function (err, stdout) { - expect(err).to.equal(null) - expect(stdout).to.equal( - '> \'use strict\'\n' + - '> ' - ) - - return done() - }) - cp.stdin!.end('const letsWrite: string = "some typescript!"\n') - }) - it('should support require flags', function (done) { exec(`${cmd} -r ./tests/hello-world -pe "console.log('success')"`, function (err, stdout) { expect(err).to.equal(null) diff --git a/tests/repl.ts b/tests/repl.ts deleted file mode 100644 index d24cde4b4..000000000 --- a/tests/repl.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { start } from 'repl' -import { createReplEval, register } from '../src/index' - -const service = register() -start({ - prompt: '> ', - input: process.stdin, - output: process.stdout, - terminal: process.stdout.isTTY && !parseInt(process.env.NODE_NO_READLINE!, 10), - eval: createReplEval(service), - useGlobal: true -}) From 9ab7ecaf87418425eed09aa87089f42c9c117c7a Mon Sep 17 00:00:00 2001 From: MarcManiez Date: Fri, 4 Sep 2020 15:02:17 -0700 Subject: [PATCH 06/18] Use service creation pattern --- src/bin.ts | 4 ++-- src/index.ts | 52 +++++++++++++++++++++++++++------------------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 9237e700e..6b9c3bca4 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -10,7 +10,7 @@ import { homedir } from 'os' import { _eval, appendEval, - createReplEval, + createReplService, EVAL_FILENAME, EvalState, exec, @@ -319,7 +319,7 @@ function startRepl (service: Register, state: EvalState, code?: string) { output: process.stdout, // Mimicking node's REPL implementation: https://github.com/nodejs/node/blob/168b22ba073ee1cbf8d0bcb4ded7ff3099335d04/lib/internal/repl.js#L28-L30 terminal: process.stdout.isTTY && !parseInt(process.env.NODE_NO_READLINE!, 10), - eval: createReplEval(service, state), + eval: createReplService(service, state).eval, useGlobal: true }) diff --git a/src/index.ts b/src/index.ts index 9d8252c2a..75705725d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1001,39 +1001,41 @@ export class EvalState { * * @returns an evaluator for the node REPL */ -export function createReplEval ( +export function createReplService ( service: Register, state: EvalState = new EvalState(join(process.cwd(), EVAL_FILENAME)) ) { - /** - * Eval code from the REPL. - */ - return function (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any) { - let err: Error | null = null - let result: any - - // TODO: Figure out how to handle completion here. - if (code === '.scope') { - callback(err) - return - } + return { + /** + * Eval code from the REPL. + */ + eval: function (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any) { + let err: Error | null = null + let result: any + + // TODO: Figure out how to handle completion here. + if (code === '.scope') { + callback(err) + return + } - try { - result = _eval(service, state, code) - } catch (error) { - if (error instanceof TSError) { - // Support recoverable compilations using >= node 6. - if (Recoverable && isRecoverable(error)) { - err = new Recoverable(error) + try { + result = _eval(service, state, code) + } catch (error) { + if (error instanceof TSError) { + // Support recoverable compilations using >= node 6. + if (Recoverable && isRecoverable(error)) { + err = new Recoverable(error) + } else { + console.error(error) + } } else { - console.error(error) + err = error } - } else { - err = error } - } - return callback(err, result) + return callback(err, result) + } } } From 58354c35a5601fffb27479028e3d48025fd4b13e Mon Sep 17 00:00:00 2001 From: MarcManiez Date: Fri, 4 Sep 2020 15:24:13 -0700 Subject: [PATCH 07/18] Move REPL code to its own file --- src/bin.ts | 85 ++----------------- src/index.ts | 165 ------------------------------------ src/repl.ts | 233 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 243 deletions(-) create mode 100644 src/repl.ts diff --git a/src/bin.ts b/src/bin.ts index 6b9c3bca4..5acd53ab4 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,25 +1,23 @@ #!/usr/bin/env node import { join, resolve, dirname } from 'path' -import { start } from 'repl' import { inspect } from 'util' import Module = require('module') import arg = require('arg') -import { readFileSync, statSync, realpathSync } from 'fs' -import { homedir } from 'os' +import { readFileSync, statSync } from 'fs' import { - _eval, - appendEval, - createReplService, - EVAL_FILENAME, - EvalState, - exec, parse, Register, register, TSError, VERSION } from './index' +import { + _eval, + EVAL_FILENAME, + EvalState, + startRepl + } from './repl' /** * Main `bin` functionality. @@ -299,75 +297,6 @@ function evalAndExit (service: Register, state: EvalState, module: Module, code: } } -/** - * Start a CLI REPL. - */ -function startRepl (service: Register, state: EvalState, code?: string) { - // Eval incoming code before the REPL starts. - if (code) { - try { - _eval(service, state, `${code}\n`) - } catch (err) { - console.error(err) - process.exit(1) - } - } - - const repl = start({ - prompt: '> ', - input: process.stdin, - output: process.stdout, - // Mimicking node's REPL implementation: https://github.com/nodejs/node/blob/168b22ba073ee1cbf8d0bcb4ded7ff3099335d04/lib/internal/repl.js#L28-L30 - terminal: process.stdout.isTTY && !parseInt(process.env.NODE_NO_READLINE!, 10), - eval: createReplService(service, state).eval, - useGlobal: true - }) - - // Bookmark the point where we should reset the REPL state. - const resetEval = appendEval(state, '') - - function reset () { - resetEval() - - // Hard fix for TypeScript forcing `Object.defineProperty(exports, ...)`. - exec('exports = module.exports', state.path) - } - - reset() - repl.on('reset', reset) - - repl.defineCommand('type', { - help: 'Check the type of a TypeScript identifier', - action: function (identifier: string) { - if (!identifier) { - repl.displayPrompt() - return - } - - const undo = appendEval(state, identifier) - const { name, comment } = service.getTypeInfo(state.input, state.path, state.input.length) - - undo() - - if (name) repl.outputStream.write(`${name}\n`) - if (comment) repl.outputStream.write(`${comment}\n`) - repl.displayPrompt() - } - }) - - // Set up REPL history when available natively via node.js >= 11. - if (repl.setupHistory) { - const historyPath = process.env.TS_NODE_HISTORY || join(homedir(), '.ts_node_repl_history') - - repl.setupHistory(historyPath, err => { - if (!err) return - - console.error(err) - process.exit(1) - }) - } -} - /** Safe `hasOwnProperty` */ function hasOwnProperty (object: any, property: string): boolean { return Object.prototype.hasOwnProperty.call(object, property) diff --git a/src/index.ts b/src/index.ts index 75705725d..7286bd860 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,5 @@ -import { diffLines } from 'diff' import { relative, basename, extname, resolve, dirname, join } from 'path' -import { Recoverable } from 'repl' import sourceMapSupport = require('source-map-support') -import { Script } from 'vm' import * as ynModule from 'yn' import { BaseError } from 'make-error' import * as util from 'util' @@ -45,12 +42,6 @@ declare global { } } -/** - * @internal - * Eval filename for REPL/debug. - */ -export const EVAL_FILENAME = `[eval].ts` - /** * @internal */ @@ -983,162 +974,6 @@ export function create (rawOptions: CreateOptions = {}): Register { return { ts, config, compile, getTypeInfo, ignored, enabled, options } } -/** - * Eval state management. - */ -export class EvalState { - input = '' - output = '' - version = 0 - lines = 0 - - constructor (public path: string) {} -} - -/** - * @param service - TypeScript compiler instance - * @param state - Eval state management - * - * @returns an evaluator for the node REPL - */ -export function createReplService ( - service: Register, - state: EvalState = new EvalState(join(process.cwd(), EVAL_FILENAME)) -) { - return { - /** - * Eval code from the REPL. - */ - eval: function (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any) { - let err: Error | null = null - let result: any - - // TODO: Figure out how to handle completion here. - if (code === '.scope') { - callback(err) - return - } - - try { - result = _eval(service, state, code) - } catch (error) { - if (error instanceof TSError) { - // Support recoverable compilations using >= node 6. - if (Recoverable && isRecoverable(error)) { - err = new Recoverable(error) - } else { - console.error(error) - } - } else { - err = error - } - } - - return callback(err, result) - } - } -} - -/** - * @internal - * Append to the eval instance and return an undo function. - */ -export function appendEval (state: EvalState, input: string) { - const undoInput = state.input - const undoVersion = state.version - const undoOutput = state.output - const undoLines = state.lines - - // Handle ASI issues with TypeScript re-evaluation. - if (undoInput.charAt(undoInput.length - 1) === '\n' && /^\s*[\/\[(`-]/.test(input) && !/;\s*$/.test(undoInput)) { - state.input = `${state.input.slice(0, -1)};\n` - } - - state.input += input - state.lines += lineCount(input) - state.version++ - - return function () { - state.input = undoInput - state.output = undoOutput - state.version = undoVersion - state.lines = undoLines - } -} - -/** - * Count the number of lines. - */ -function lineCount (value: string) { - let count = 0 - - for (const char of value) { - if (char === '\n') { - count++ - } - } - - return count -} - -/** - * @internal - * Evaluate the code snippet. - */ -export function _eval (service: Register, state: EvalState, input: string) { - const lines = state.lines - const isCompletion = !/\n$/.test(input) - const undo = appendEval(state, input) - let output: string - - try { - output = service.compile(state.input, state.path, -lines) - } catch (err) { - undo() - throw err - } - - // Use `diff` to check for new JavaScript to execute. - const changes = diffLines(state.output, output) - - if (isCompletion) { - undo() - } else { - state.output = output - } - - return changes.reduce((result, change) => { - return change.added ? exec(change.value, state.path) : result - }, undefined) -} - -/** - * @internal - * Execute some code. - */ -export function exec (code: string, filename: string) { - const script = new Script(code, { filename: filename }) - - return script.runInThisContext() -} - -const RECOVERY_CODES: Set = new Set([ - 1003, // "Identifier expected." - 1005, // "')' expected." - 1109, // "Expression expected." - 1126, // "Unexpected end of text." - 1160, // "Unterminated template literal." - 1161, // "Unterminated regular expression literal." - 2355 // "A function whose declared type is neither 'void' nor 'any' must return a value." -]) - -/** - * Check if a function can recover gracefully. - */ -function isRecoverable (error: TSError) { - return error.diagnosticCodes.every(code => RECOVERY_CODES.has(code)) -} - /** * Check if the filename should be ignored. */ diff --git a/src/repl.ts b/src/repl.ts new file mode 100644 index 000000000..bd14b214a --- /dev/null +++ b/src/repl.ts @@ -0,0 +1,233 @@ +import { diffLines } from 'diff' +import { homedir } from 'os' +import { join } from 'path' +import { Recoverable, start } from 'repl' +import { Script } from 'vm' +import { Register, TSError } from './index' + +/** + * Eval filename for REPL/debug. + */ +export const EVAL_FILENAME = `[eval].ts` + +/** + * Evaluate the code snippet. + */ +export function _eval (service: Register, state: EvalState, input: string) { + const lines = state.lines + const isCompletion = !/\n$/.test(input) + const undo = appendEval(state, input) + let output: string + + try { + output = service.compile(state.input, state.path, -lines) + } catch (err) { + undo() + throw err + } + + // Use `diff` to check for new JavaScript to execute. + const changes = diffLines(state.output, output) + + if (isCompletion) { + undo() + } else { + state.output = output + } + + return changes.reduce((result, change) => { + return change.added ? exec(change.value, state.path) : result + }, undefined) +} + +/** + * Eval state management. + */ +export class EvalState { + input = '' + output = '' + version = 0 + lines = 0 + + constructor (public path: string) {} +} + +/** + * @param service - TypeScript compiler instance + * @param state - Eval state management + * + * @returns an evaluator for the node REPL + */ +export function createReplService ( + service: Register, + state: EvalState = new EvalState(join(process.cwd(), EVAL_FILENAME)) +) { + return { + /** + * Eval code from the REPL. + */ + eval: function (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any) { + let err: Error | null = null + let result: any + + // TODO: Figure out how to handle completion here. + if (code === '.scope') { + callback(err) + return + } + + try { + result = _eval(service, state, code) + } catch (error) { + if (error instanceof TSError) { + // Support recoverable compilations using >= node 6. + if (Recoverable && isRecoverable(error)) { + err = new Recoverable(error) + } else { + console.error(error) + } + } else { + err = error + } + } + + return callback(err, result) + } + } +} + +/** + * Append to the eval instance and return an undo function. + */ +export function appendEval (state: EvalState, input: string) { + const undoInput = state.input + const undoVersion = state.version + const undoOutput = state.output + const undoLines = state.lines + + // Handle ASI issues with TypeScript re-evaluation. + if (undoInput.charAt(undoInput.length - 1) === '\n' && /^\s*[\/\[(`-]/.test(input) && !/;\s*$/.test(undoInput)) { + state.input = `${state.input.slice(0, -1)};\n` + } + + state.input += input + state.lines += lineCount(input) + state.version++ + + return function () { + state.input = undoInput + state.output = undoOutput + state.version = undoVersion + state.lines = undoLines + } +} + +/** + * Count the number of lines. + */ +function lineCount (value: string) { + let count = 0 + + for (const char of value) { + if (char === '\n') { + count++ + } + } + + return count +} + +/** + * Execute some code. + */ +export function exec (code: string, filename: string) { + const script = new Script(code, { filename: filename }) + + return script.runInThisContext() +} + +const RECOVERY_CODES: Set = new Set([ + 1003, // "Identifier expected." + 1005, // "')' expected." + 1109, // "Expression expected." + 1126, // "Unexpected end of text." + 1160, // "Unterminated template literal." + 1161, // "Unterminated regular expression literal." + 2355 // "A function whose declared type is neither 'void' nor 'any' must return a value." +]) + +/** + * Check if a function can recover gracefully. + */ +function isRecoverable (error: TSError) { + return error.diagnosticCodes.every(code => RECOVERY_CODES.has(code)) +} + +/** + * Start a CLI REPL. + */ +export function startRepl (service: Register, state: EvalState, code?: string) { + // Eval incoming code before the REPL starts. + if (code) { + try { + _eval(service, state, `${code}\n`) + } catch (err) { + console.error(err) + process.exit(1) + } + } + + const repl = start({ + prompt: '> ', + input: process.stdin, + output: process.stdout, + // Mimicking node's REPL implementation: https://github.com/nodejs/node/blob/168b22ba073ee1cbf8d0bcb4ded7ff3099335d04/lib/internal/repl.js#L28-L30 + terminal: process.stdout.isTTY && !parseInt(process.env.NODE_NO_READLINE!, 10), + eval: createReplService(service, state).eval, + useGlobal: true + }) + + // Bookmark the point where we should reset the REPL state. + const resetEval = appendEval(state, '') + + function reset () { + resetEval() + + // Hard fix for TypeScript forcing `Object.defineProperty(exports, ...)`. + exec('exports = module.exports', state.path) + } + + reset() + repl.on('reset', reset) + + repl.defineCommand('type', { + help: 'Check the type of a TypeScript identifier', + action: function (identifier: string) { + if (!identifier) { + repl.displayPrompt() + return + } + + const undo = appendEval(state, identifier) + const { name, comment } = service.getTypeInfo(state.input, state.path, state.input.length) + + undo() + + if (name) repl.outputStream.write(`${name}\n`) + if (comment) repl.outputStream.write(`${comment}\n`) + repl.displayPrompt() + } + }) + + // Set up REPL history when available natively via node.js >= 11. + if (repl.setupHistory) { + const historyPath = process.env.TS_NODE_HISTORY || join(homedir(), '.ts_node_repl_history') + + repl.setupHistory(historyPath, err => { + if (!err) return + + console.error(err) + process.exit(1) + }) + } +} From 02c174a7f573e9120ed346b210bc3ac5a6510ed6 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Fri, 20 Nov 2020 09:13:30 -0500 Subject: [PATCH 08/18] - Reorder repl.ts declarations to match the original order from bin.ts - promote createReplService to top of the file since it will hopefully be the main entrypoint to any REPL functionality --- src/repl.ts | 176 ++++++++++++++++++++++++++-------------------------- 1 file changed, 88 insertions(+), 88 deletions(-) diff --git a/src/repl.ts b/src/repl.ts index bd14b214a..c9c6f6127 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -10,48 +10,6 @@ import { Register, TSError } from './index' */ export const EVAL_FILENAME = `[eval].ts` -/** - * Evaluate the code snippet. - */ -export function _eval (service: Register, state: EvalState, input: string) { - const lines = state.lines - const isCompletion = !/\n$/.test(input) - const undo = appendEval(state, input) - let output: string - - try { - output = service.compile(state.input, state.path, -lines) - } catch (err) { - undo() - throw err - } - - // Use `diff` to check for new JavaScript to execute. - const changes = diffLines(state.output, output) - - if (isCompletion) { - undo() - } else { - state.output = output - } - - return changes.reduce((result, change) => { - return change.added ? exec(change.value, state.path) : result - }, undefined) -} - -/** - * Eval state management. - */ -export class EvalState { - input = '' - output = '' - version = 0 - lines = 0 - - constructor (public path: string) {} -} - /** * @param service - TypeScript compiler instance * @param state - Eval state management @@ -97,44 +55,45 @@ export function createReplService ( } /** - * Append to the eval instance and return an undo function. + * Eval state management. */ -export function appendEval (state: EvalState, input: string) { - const undoInput = state.input - const undoVersion = state.version - const undoOutput = state.output - const undoLines = state.lines - - // Handle ASI issues with TypeScript re-evaluation. - if (undoInput.charAt(undoInput.length - 1) === '\n' && /^\s*[\/\[(`-]/.test(input) && !/;\s*$/.test(undoInput)) { - state.input = `${state.input.slice(0, -1)};\n` - } - - state.input += input - state.lines += lineCount(input) - state.version++ +export class EvalState { + input = '' + output = '' + version = 0 + lines = 0 - return function () { - state.input = undoInput - state.output = undoOutput - state.version = undoVersion - state.lines = undoLines - } + constructor (public path: string) {} } /** - * Count the number of lines. + * Evaluate the code snippet. */ -function lineCount (value: string) { - let count = 0 +export function _eval (service: Register, state: EvalState, input: string) { + const lines = state.lines + const isCompletion = !/\n$/.test(input) + const undo = appendEval(state, input) + let output: string - for (const char of value) { - if (char === '\n') { - count++ - } + try { + output = service.compile(state.input, state.path, -lines) + } catch (err) { + undo() + throw err } - return count + // Use `diff` to check for new JavaScript to execute. + const changes = diffLines(state.output, output) + + if (isCompletion) { + undo() + } else { + state.output = output + } + + return changes.reduce((result, change) => { + return change.added ? exec(change.value, state.path) : result + }, undefined) } /** @@ -146,23 +105,6 @@ export function exec (code: string, filename: string) { return script.runInThisContext() } -const RECOVERY_CODES: Set = new Set([ - 1003, // "Identifier expected." - 1005, // "')' expected." - 1109, // "Expression expected." - 1126, // "Unexpected end of text." - 1160, // "Unterminated template literal." - 1161, // "Unterminated regular expression literal." - 2355 // "A function whose declared type is neither 'void' nor 'any' must return a value." -]) - -/** - * Check if a function can recover gracefully. - */ -function isRecoverable (error: TSError) { - return error.diagnosticCodes.every(code => RECOVERY_CODES.has(code)) -} - /** * Start a CLI REPL. */ @@ -231,3 +173,61 @@ export function startRepl (service: Register, state: EvalState, code?: string) { }) } } + +/** + * Append to the eval instance and return an undo function. + */ +export function appendEval (state: EvalState, input: string) { + const undoInput = state.input + const undoVersion = state.version + const undoOutput = state.output + const undoLines = state.lines + + // Handle ASI issues with TypeScript re-evaluation. + if (undoInput.charAt(undoInput.length - 1) === '\n' && /^\s*[\/\[(`-]/.test(input) && !/;\s*$/.test(undoInput)) { + state.input = `${state.input.slice(0, -1)};\n` + } + + state.input += input + state.lines += lineCount(input) + state.version++ + + return function () { + state.input = undoInput + state.output = undoOutput + state.version = undoVersion + state.lines = undoLines + } +} + +/** + * Count the number of lines. + */ +function lineCount (value: string) { + let count = 0 + + for (const char of value) { + if (char === '\n') { + count++ + } + } + + return count +} + +const RECOVERY_CODES: Set = new Set([ + 1003, // "Identifier expected." + 1005, // "')' expected." + 1109, // "Expression expected." + 1126, // "Unexpected end of text." + 1160, // "Unterminated template literal." + 1161, // "Unterminated regular expression literal." + 2355 // "A function whose declared type is neither 'void' nor 'any' must return a value." +]) + +/** + * Check if a function can recover gracefully. + */ +function isRecoverable (error: TSError) { + return error.diagnosticCodes.every(code => RECOVERY_CODES.has(code)) +} From 95375d50babe8de880829d4cb2e89ea2e408b2a0 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Fri, 20 Nov 2020 10:34:03 -0500 Subject: [PATCH 09/18] Expand ReplService API; use new API in bin.ts, which further decouples bin.ts from repl.ts --- src/bin.ts | 42 +++++---------- src/repl.ts | 145 ++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 116 insertions(+), 71 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index bb8e45b7c..1fd8df65e 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -13,10 +13,10 @@ import { VERSION } from './index' import { - _eval, EVAL_FILENAME, EvalState, - startRepl + createReplService, + ReplService } from './repl' /** @@ -151,6 +151,8 @@ export function main (argv: string[] = process.argv.slice(2), entrypointArgs: Re /** Unresolved. May point to a symlink, not realpath. May be missing file extension */ const scriptPath = args._.length ? resolve(cwd, args._[0]) : undefined const state = new EvalState(scriptPath || join(cwd, EVAL_FILENAME)) + const replService = createReplService({ state }) + const { evalStateAwareHostFunctions } = replService // Register the TypeScript compiler instance. const service = register({ @@ -171,29 +173,13 @@ export function main (argv: string[] = process.argv.slice(2), entrypointArgs: Re ignoreDiagnostics, compilerOptions, require: argsRequire, - readFile: code !== undefined - ? (path: string) => { - if (path === state.path) return state.input - - try { - return readFileSync(path, 'utf8') - } catch (err) {/* Ignore. */} - } - : undefined, - fileExists: code !== undefined - ? (path: string) => { - if (path === state.path) return true - - try { - const stats = statSync(path) - return stats.isFile() || stats.isFIFO() - } catch (err) { - return false - } - } - : undefined + readFile: code !== undefined ? evalStateAwareHostFunctions.readFile : undefined, + fileExists: code !== undefined ? evalStateAwareHostFunctions.fileExists : undefined }) + // Bind REPL service to ts-node compiler service (chicken-and-egg problem) + replService.setService(service) + // Output project information. if (version >= 2) { console.log(`ts-node v${VERSION}`) @@ -213,7 +199,7 @@ export function main (argv: string[] = process.argv.slice(2), entrypointArgs: Re // Execute the main contents (either eval, script or piped). if (code !== undefined && !interactive) { - evalAndExit(service, state, module, code, print) + evalAndExit(replService, module, code, print) } else { if (args._.length) { Module.runMain() @@ -221,11 +207,11 @@ export function main (argv: string[] = process.argv.slice(2), entrypointArgs: Re // Piping of execution _only_ occurs when no other script is specified. // --interactive flag forces REPL if (interactive || process.stdin.isTTY) { - startRepl(service, state, code) + replService.start(code) } else { let buffer = code || '' process.stdin.on('data', (chunk: Buffer) => buffer += chunk) - process.stdin.on('end', () => evalAndExit(service, state, module, buffer, print)) + process.stdin.on('end', () => evalAndExit(replService, module, buffer, print)) } } } @@ -275,7 +261,7 @@ function getCwd (dir?: string, scriptMode?: boolean, scriptPath?: string) { /** * Evaluate a script. */ -function evalAndExit (service: Register, state: EvalState, module: Module, code: string, isPrinted: boolean) { +function evalAndExit (replService: ReplService, module: Module, code: string, isPrinted: boolean) { let result: any ;(global as any).__filename = module.filename @@ -285,7 +271,7 @@ function evalAndExit (service: Register, state: EvalState, module: Module, code: ;(global as any).require = module.require.bind(module) try { - result = _eval(service, state, code) + result = replService.evalCode(code) } catch (error) { if (error instanceof TSError) { console.error(error) diff --git a/src/repl.ts b/src/repl.ts index c9c6f6127..54689b5ba 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -3,54 +3,90 @@ import { homedir } from 'os' import { join } from 'path' import { Recoverable, start } from 'repl' import { Script } from 'vm' -import { Register, TSError } from './index' +import { Register, CreateOptions, TSError } from './index' +import { readFileSync, statSync } from 'fs' /** * Eval filename for REPL/debug. + * @internal */ export const EVAL_FILENAME = `[eval].ts` -/** - * @param service - TypeScript compiler instance - * @param state - Eval state management - * - * @returns an evaluator for the node REPL - */ -export function createReplService ( - service: Register, - state: EvalState = new EvalState(join(process.cwd(), EVAL_FILENAME)) -) { - return { - /** - * Eval code from the REPL. - */ - eval: function (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any) { - let err: Error | null = null - let result: any - - // TODO: Figure out how to handle completion here. - if (code === '.scope') { - callback(err) - return - } +export interface ReplService { + readonly state: EvalState + setService (service: Register): void + evalCode (code: string): void + /** + * eval implementation compatible with node's REPL API + */ + nodeReplEval (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any): void + evalStateAwareHostFunctions: EvalStateAwareHostFunctions + /** Start a node REPL */ + start (code?: string): void +} + +export interface CreateReplServiceOptions { + service?: Register + state?: EvalState +} + +export function createReplService (options: CreateReplServiceOptions = {}) { + let service = options.service + const state = options.state ?? new EvalState(join(process.cwd(), EVAL_FILENAME)) + const evalStateAwareHostFunctions = createEvalStateAwareHostFunctions(state) + + const replService: ReplService = { + state: options.state ?? new EvalState(join(process.cwd(), EVAL_FILENAME)), + setService, + evalCode, + nodeReplEval, + evalStateAwareHostFunctions, + start + } + return replService + + function setService (_service: Register) { + service = _service + } + + function evalCode (code: string) { + return _eval(service!, state, code) + } + + /** + * Eval code from the REPL. + */ + function nodeReplEval (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any) { + let err: Error | null = null + let result: any + + // TODO: Figure out how to handle completion here. + if (code === '.scope') { + callback(err) + return + } - try { - result = _eval(service, state, code) - } catch (error) { - if (error instanceof TSError) { - // Support recoverable compilations using >= node 6. - if (Recoverable && isRecoverable(error)) { - err = new Recoverable(error) - } else { - console.error(error) - } + try { + result = evalCode(code) + } catch (error) { + if (error instanceof TSError) { + // Support recoverable compilations using >= node 6. + if (Recoverable && isRecoverable(error)) { + err = new Recoverable(error) } else { - err = error + console.error(error) } + } else { + err = error } - - return callback(err, result) } + + return callback(err, result) + } + + function start (code?: string) { + // TODO assert that service is set; remove all ! postfixes + return startRepl(replService, service!, state, code) } } @@ -63,13 +99,36 @@ export class EvalState { version = 0 lines = 0 - constructor (public path: string) {} + constructor (public path: string) { } +} + +export type EvalStateAwareHostFunctions = Pick + +export function createEvalStateAwareHostFunctions (state: EvalState): EvalStateAwareHostFunctions { + function readFile (path: string) { + if (path === state.path) return state.input + + try { + return readFileSync(path, 'utf8') + } catch (err) {/* Ignore. */} + } + function fileExists (path: string) { + if (path === state.path) return true + + try { + const stats = statSync(path) + return stats.isFile() || stats.isFIFO() + } catch (err) { + return false + } + } + return { readFile, fileExists } } /** * Evaluate the code snippet. */ -export function _eval (service: Register, state: EvalState, input: string) { +function _eval (service: Register, state: EvalState, input: string) { const lines = state.lines const isCompletion = !/\n$/.test(input) const undo = appendEval(state, input) @@ -99,7 +158,7 @@ export function _eval (service: Register, state: EvalState, input: string) { /** * Execute some code. */ -export function exec (code: string, filename: string) { +function exec (code: string, filename: string) { const script = new Script(code, { filename: filename }) return script.runInThisContext() @@ -108,11 +167,11 @@ export function exec (code: string, filename: string) { /** * Start a CLI REPL. */ -export function startRepl (service: Register, state: EvalState, code?: string) { +function startRepl (replService: ReplService, service: Register, state: EvalState, code?: string) { // Eval incoming code before the REPL starts. if (code) { try { - _eval(service, state, `${code}\n`) + replService.evalCode(`${code}\n`) } catch (err) { console.error(err) process.exit(1) @@ -125,7 +184,7 @@ export function startRepl (service: Register, state: EvalState, code?: string) { output: process.stdout, // Mimicking node's REPL implementation: https://github.com/nodejs/node/blob/168b22ba073ee1cbf8d0bcb4ded7ff3099335d04/lib/internal/repl.js#L28-L30 terminal: process.stdout.isTTY && !parseInt(process.env.NODE_NO_READLINE!, 10), - eval: createReplService(service, state).eval, + eval: replService.nodeReplEval, useGlobal: true }) From 7acec12071c234876e4bd1f53a56f2ce95d651e2 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 21 Nov 2020 16:56:52 -0500 Subject: [PATCH 10/18] Add brief, internal docs for REPL API --- development-docs/repl-api.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 development-docs/repl-api.md diff --git a/development-docs/repl-api.md b/development-docs/repl-api.md new file mode 100644 index 000000000..ed6ecafcf --- /dev/null +++ b/development-docs/repl-api.md @@ -0,0 +1,27 @@ +## How to create your own ts-node powered REPL + +- Create ts-node REPL service which includes EvalState +- Create ts-node service using EvalState-aware `readFile` and `fileExists` implementations from REPL +- Pass service to repl service +- Either: + - call REPL method start() to start a REPL + - create your own node repl but pass it REPL service's eval() function + + +``` +const tsNodeReplService = tsNode.createReplService() +const {readFile, fileExists} = repl.getStateAwareHostFunctions() +const service = tsNode.register({ + ... options, + readFile, + fileExists +}) +tsNodeReplService.setService(service); +tsNodeReplService.start(); + +// or +const repl = require('repl').start({ + ... options, + eval: tsNodeReplService.nodeReplEval +}); +``` From b2f13e3bd88442ddb5c30ad519d573f5127d3c58 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 21 Nov 2020 16:58:29 -0500 Subject: [PATCH 11/18] Add support for DI of alternative stdio streams into REPL --- src/repl.ts | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/repl.ts b/src/repl.ts index 54689b5ba..98ee153c1 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -5,6 +5,8 @@ import { Recoverable, start } from 'repl' import { Script } from 'vm' import { Register, CreateOptions, TSError } from './index' import { readFileSync, statSync } from 'fs' +import { Console } from 'console' +import * as tty from 'tty' /** * Eval filename for REPL/debug. @@ -23,17 +25,32 @@ export interface ReplService { evalStateAwareHostFunctions: EvalStateAwareHostFunctions /** Start a node REPL */ start (code?: string): void + /** @internal */ + readonly stdin: NodeJS.ReadableStream + /** @internal */ + readonly stdout: NodeJS.WritableStream + /** @internal */ + readonly stderr: NodeJS.WritableStream + /** @internal */ + readonly console: Console } export interface CreateReplServiceOptions { service?: Register state?: EvalState + stdin?: NodeJS.ReadableStream + stdout?: NodeJS.WritableStream + stderr?: NodeJS.WritableStream } export function createReplService (options: CreateReplServiceOptions = {}) { let service = options.service const state = options.state ?? new EvalState(join(process.cwd(), EVAL_FILENAME)) const evalStateAwareHostFunctions = createEvalStateAwareHostFunctions(state) + const stdin = options.stdin ?? process.stdin + const stdout = options.stdout ?? process.stdout + const stderr = options.stderr ?? process.stderr + const _console = stdout === process.stdout && stderr === process.stderr ? console : new Console(stdout, stderr) const replService: ReplService = { state: options.state ?? new EvalState(join(process.cwd(), EVAL_FILENAME)), @@ -41,7 +58,11 @@ export function createReplService (options: CreateReplServiceOptions = {}) { evalCode, nodeReplEval, evalStateAwareHostFunctions, - start + start, + stdin, + stdout, + stderr, + console: _console } return replService @@ -173,17 +194,17 @@ function startRepl (replService: ReplService, service: Register, state: EvalStat try { replService.evalCode(`${code}\n`) } catch (err) { - console.error(err) + replService.console.error(err) process.exit(1) } } const repl = start({ prompt: '> ', - input: process.stdin, - output: process.stdout, + input: replService.stdin, + output: replService.stdout, // Mimicking node's REPL implementation: https://github.com/nodejs/node/blob/168b22ba073ee1cbf8d0bcb4ded7ff3099335d04/lib/internal/repl.js#L28-L30 - terminal: process.stdout.isTTY && !parseInt(process.env.NODE_NO_READLINE!, 10), + terminal: (replService.stdout as tty.WriteStream).isTTY && !parseInt(process.env.NODE_NO_READLINE!, 10), eval: replService.nodeReplEval, useGlobal: true }) @@ -227,7 +248,7 @@ function startRepl (replService: ReplService, service: Register, state: EvalStat repl.setupHistory(historyPath, err => { if (!err) return - console.error(err) + replService.console.error(err) process.exit(1) }) } From d539f40196caa01319a84ee0703b76bb41356413 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 21 Nov 2020 17:01:55 -0500 Subject: [PATCH 12/18] export REPL from index.ts --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index ea9cd780c..dc21c89ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ import { fileURLToPath } from 'url' import type * as _ts from 'typescript' import * as Module from 'module' +export { createReplService, CreateReplServiceOptions, ReplService } from './repl' + /** * Does this version of node obey the package.json "type" field * and throw ERR_REQUIRE_ESM when attempting to require() an ESM modules. From 4164a375d2d2e2a73e63a84da8aa232fa41b02f7 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 21 Nov 2020 17:02:05 -0500 Subject: [PATCH 13/18] remove unnecessary export --- src/repl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repl.ts b/src/repl.ts index 98ee153c1..47d896f6f 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -257,7 +257,7 @@ function startRepl (replService: ReplService, service: Register, state: EvalStat /** * Append to the eval instance and return an undo function. */ -export function appendEval (state: EvalState, input: string) { +function appendEval (state: EvalState, input: string) { const undoInput = state.input const undoVersion = state.version const undoOutput = state.output From 9b834b01f6e07111cc69e4d333268e728239158c Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 21 Nov 2020 17:03:16 -0500 Subject: [PATCH 14/18] Add test for new REPL API --- package-lock.json | 6 ++++++ package.json | 1 + src/index.spec.ts | 36 +++++++++++++++++++++++++++++++++--- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 997c65b9a..5065caecf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -979,6 +979,12 @@ "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", "dev": true }, + "get-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz", + "integrity": "sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==", + "dev": true + }, "globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", diff --git a/package.json b/package.json index 2e6cc5a1d..0531136e1 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "@types/source-map-support": "^0.5.0", "axios": "^0.19.0", "chai": "^4.0.1", + "get-stream": "^6.0.0", "lodash": "^4.17.15", "mocha": "^6.2.2", "ntypescript": "^1.201507091536.1", diff --git a/src/index.spec.ts b/src/index.spec.ts index 9762a792b..035b13f60 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -10,7 +10,9 @@ import * as promisify from 'util.promisify' import { sync as rimrafSync } from 'rimraf' import { createRequire, createRequireFromPath } from 'module' import { pathToFileURL } from 'url' -import Module = require('module') +import type * as Module from 'module' +import { PassThrough } from 'stream' +import * as getStream from 'get-stream' const execP = promisify(exec) @@ -25,7 +27,7 @@ const SOURCE_MAP_REGEXP = /\/\/# sourceMappingURL=data:application\/json;charset const testsDirRequire = (createRequire || createRequireFromPath)(join(TEST_DIR, 'index.js')) // tslint:disable-line // Set after ts-node is installed locally -let { register, create, VERSION }: typeof tsNodeTypes = {} as any +let { register, create, VERSION, createReplService }: typeof tsNodeTypes = {} as any // Pack and install ts-node locally, necessary to test package "exports" before(async function () { @@ -34,7 +36,7 @@ before(async function () { await execP(`npm install`, { cwd: TEST_DIR }) const packageLockPath = join(TEST_DIR, 'package-lock.json') existsSync(packageLockPath) && unlinkSync(packageLockPath) - ;({ register, create, VERSION } = testsDirRequire('ts-node')) + ;({ register, create, VERSION, createReplService } = testsDirRequire('ts-node')) }) describe('ts-node', function () { @@ -370,6 +372,34 @@ describe('ts-node', function () { cp.stdin!.end('\nconst a = 123\n.type a') }) + it('REPL can be created via API', async () => { + const stdin = new PassThrough() + const stdout = new PassThrough() + const stderr = new PassThrough() + const replService = createReplService({ + stdin, + stdout, + stderr + }) + const service = create(replService.evalStateAwareHostFunctions) + replService.setService(service) + replService.start() + stdin.write( + 'const a = 123\n' + + '.type a\n' + ) + stdin.end() + await promisify(setTimeout)(100) + stdout.end() + stderr.end() + expect(await getStream(stderr)).to.equal('') + expect(await getStream(stdout)).to.equal( + '> \'use strict\'\n' + + '> const a: 123\n' + + '> ' + ) + }) + it('should support require flags', function (done) { exec(`${cmd} -r ./tests/hello-world -pe "console.log('success')"`, function (err, stdout) { expect(err).to.equal(null) From 495475efb960b01ec2c68fc09dce69a6c2ac19ef Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 21 Nov 2020 17:30:44 -0500 Subject: [PATCH 15/18] API surface, naming, docs tweaks --- src/bin.ts | 8 +++----- src/index.spec.ts | 4 ++-- src/repl.ts | 31 +++++++++++++++++++++---------- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 1fd8df65e..2afd42d14 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -4,10 +4,8 @@ import { join, resolve, dirname } from 'path' import { inspect } from 'util' import Module = require('module') import arg = require('arg') -import { readFileSync, statSync } from 'fs' import { parse, - Register, register, TSError, VERSION @@ -152,7 +150,7 @@ export function main (argv: string[] = process.argv.slice(2), entrypointArgs: Re const scriptPath = args._.length ? resolve(cwd, args._[0]) : undefined const state = new EvalState(scriptPath || join(cwd, EVAL_FILENAME)) const replService = createReplService({ state }) - const { evalStateAwareHostFunctions } = replService + const { evalAwarePartialHost } = replService // Register the TypeScript compiler instance. const service = register({ @@ -173,8 +171,8 @@ export function main (argv: string[] = process.argv.slice(2), entrypointArgs: Re ignoreDiagnostics, compilerOptions, require: argsRequire, - readFile: code !== undefined ? evalStateAwareHostFunctions.readFile : undefined, - fileExists: code !== undefined ? evalStateAwareHostFunctions.fileExists : undefined + readFile: code !== undefined ? evalAwarePartialHost.readFile : undefined, + fileExists: code !== undefined ? evalAwarePartialHost.fileExists : undefined }) // Bind REPL service to ts-node compiler service (chicken-and-egg problem) diff --git a/src/index.spec.ts b/src/index.spec.ts index 035b13f60..a08aaee1a 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -10,7 +10,7 @@ import * as promisify from 'util.promisify' import { sync as rimrafSync } from 'rimraf' import { createRequire, createRequireFromPath } from 'module' import { pathToFileURL } from 'url' -import type * as Module from 'module' +import Module = require('module') import { PassThrough } from 'stream' import * as getStream from 'get-stream' @@ -381,7 +381,7 @@ describe('ts-node', function () { stdout, stderr }) - const service = create(replService.evalStateAwareHostFunctions) + const service = create(replService.evalAwarePartialHost) replService.setService(service) replService.start() stdin.write( diff --git a/src/repl.ts b/src/repl.ts index 47d896f6f..7b73652c0 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -16,13 +16,16 @@ export const EVAL_FILENAME = `[eval].ts` export interface ReplService { readonly state: EvalState + /** + * Bind this REPL to a ts-node compiler service. A compiler service must be bound before `eval`-ing code or starting the REPL + */ setService (service: Register): void evalCode (code: string): void /** - * eval implementation compatible with node's REPL API + * `eval` implementation compatible with node's REPL API */ nodeReplEval (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any): void - evalStateAwareHostFunctions: EvalStateAwareHostFunctions + evalAwarePartialHost: EvalAwarePartialHost /** Start a node REPL */ start (code?: string): void /** @internal */ @@ -46,7 +49,7 @@ export interface CreateReplServiceOptions { export function createReplService (options: CreateReplServiceOptions = {}) { let service = options.service const state = options.state ?? new EvalState(join(process.cwd(), EVAL_FILENAME)) - const evalStateAwareHostFunctions = createEvalStateAwareHostFunctions(state) + const evalAwarePartialHost = createEvalAwarePartialHost(state) const stdin = options.stdin ?? process.stdin const stdout = options.stdout ?? process.stdout const stderr = options.stderr ?? process.stderr @@ -57,7 +60,7 @@ export function createReplService (options: CreateReplServiceOptions = {}) { setService, evalCode, nodeReplEval, - evalStateAwareHostFunctions, + evalAwarePartialHost, start, stdin, stdout, @@ -74,9 +77,6 @@ export function createReplService (options: CreateReplServiceOptions = {}) { return _eval(service!, state, code) } - /** - * Eval code from the REPL. - */ function nodeReplEval (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any) { let err: Error | null = null let result: any @@ -112,20 +112,31 @@ export function createReplService (options: CreateReplServiceOptions = {}) { } /** - * Eval state management. + * Eval state management. Stores virtual `[eval].ts` file */ export class EvalState { + /** @internal */ input = '' + /** @internal */ output = '' + /** @internal */ version = 0 + /** @internal */ lines = 0 + // tslint:disable-next-line:variable-name + __tsNodeEvalStateBrand: unknown + constructor (public path: string) { } } -export type EvalStateAwareHostFunctions = Pick +/** + * Filesystem host functions which are aware of the "virtual" [eval].ts file used to compile REPL inputs. + * Must be passed to `create()` to create a ts-node compiler service which can compile REPL inputs. + */ +export type EvalAwarePartialHost = Pick -export function createEvalStateAwareHostFunctions (state: EvalState): EvalStateAwareHostFunctions { +export function createEvalAwarePartialHost (state: EvalState): EvalAwarePartialHost { function readFile (path: string) { if (path === state.path) return state.input From 80f67ed80b235819cdecfbe487c7cf26a842091b Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Mon, 23 Nov 2020 15:16:55 -0500 Subject: [PATCH 16/18] tweak identifiers --- development-docs/repl-api.md | 30 +++++++++++++++--------------- src/bin.ts | 4 ++-- src/index.spec.ts | 4 ++-- src/index.ts | 2 +- src/repl.ts | 12 ++++++------ 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/development-docs/repl-api.md b/development-docs/repl-api.md index ed6ecafcf..782a13dc2 100644 --- a/development-docs/repl-api.md +++ b/development-docs/repl-api.md @@ -1,27 +1,27 @@ ## How to create your own ts-node powered REPL - Create ts-node REPL service which includes EvalState -- Create ts-node service using EvalState-aware `readFile` and `fileExists` implementations from REPL -- Pass service to repl service +- Create ts-node compiler service using EvalState-aware `readFile` and `fileExists` implementations from REPL +- Bind REPL service to compiler service (chicken-and-egg problem necessitates late binding) - Either: - call REPL method start() to start a REPL - - create your own node repl but pass it REPL service's eval() function - + - create your own node repl but pass it REPL service's nodeEval() function ``` -const tsNodeReplService = tsNode.createReplService() -const {readFile, fileExists} = repl.getStateAwareHostFunctions() -const service = tsNode.register({ +import * as tsnode from 'ts-node'; +const repl = tsnode.createRepl(); +const service = tsnode.register({ ... options, - readFile, - fileExists -}) -tsNodeReplService.setService(service); -tsNodeReplService.start(); + ...repl.evalAwarePartialHost +}); +repl.setService(service); + +// Start it +repl.start(); // or -const repl = require('repl').start({ - ... options, - eval: tsNodeReplService.nodeReplEval +const nodeRepl = require('repl').start({ + ...options, + eval: repl.nodeEval }); ``` diff --git a/src/bin.ts b/src/bin.ts index 2afd42d14..ad938fb57 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -13,7 +13,7 @@ import { import { EVAL_FILENAME, EvalState, - createReplService, + createRepl, ReplService } from './repl' @@ -149,7 +149,7 @@ export function main (argv: string[] = process.argv.slice(2), entrypointArgs: Re /** Unresolved. May point to a symlink, not realpath. May be missing file extension */ const scriptPath = args._.length ? resolve(cwd, args._[0]) : undefined const state = new EvalState(scriptPath || join(cwd, EVAL_FILENAME)) - const replService = createReplService({ state }) + const replService = createRepl({ state }) const { evalAwarePartialHost } = replService // Register the TypeScript compiler instance. diff --git a/src/index.spec.ts b/src/index.spec.ts index a08aaee1a..b2444cda9 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -27,7 +27,7 @@ const SOURCE_MAP_REGEXP = /\/\/# sourceMappingURL=data:application\/json;charset const testsDirRequire = (createRequire || createRequireFromPath)(join(TEST_DIR, 'index.js')) // tslint:disable-line // Set after ts-node is installed locally -let { register, create, VERSION, createReplService }: typeof tsNodeTypes = {} as any +let { register, create, VERSION, createRepl }: typeof tsNodeTypes = {} as any // Pack and install ts-node locally, necessary to test package "exports" before(async function () { @@ -36,7 +36,7 @@ before(async function () { await execP(`npm install`, { cwd: TEST_DIR }) const packageLockPath = join(TEST_DIR, 'package-lock.json') existsSync(packageLockPath) && unlinkSync(packageLockPath) - ;({ register, create, VERSION, createReplService } = testsDirRequire('ts-node')) + ;({ register, create, VERSION, createRepl } = testsDirRequire('ts-node')) }) describe('ts-node', function () { diff --git a/src/index.ts b/src/index.ts index dc21c89ef..1627de8c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from 'url' import type * as _ts from 'typescript' import * as Module from 'module' -export { createReplService, CreateReplServiceOptions, ReplService } from './repl' +export { createRepl, CreateReplOptions, ReplService } from './repl' /** * Does this version of node obey the package.json "type" field diff --git a/src/repl.ts b/src/repl.ts index 7b73652c0..3cadb91b1 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -24,7 +24,7 @@ export interface ReplService { /** * `eval` implementation compatible with node's REPL API */ - nodeReplEval (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any): void + nodeEval (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any): void evalAwarePartialHost: EvalAwarePartialHost /** Start a node REPL */ start (code?: string): void @@ -38,7 +38,7 @@ export interface ReplService { readonly console: Console } -export interface CreateReplServiceOptions { +export interface CreateReplOptions { service?: Register state?: EvalState stdin?: NodeJS.ReadableStream @@ -46,7 +46,7 @@ export interface CreateReplServiceOptions { stderr?: NodeJS.WritableStream } -export function createReplService (options: CreateReplServiceOptions = {}) { +export function createRepl (options: CreateReplOptions = {}) { let service = options.service const state = options.state ?? new EvalState(join(process.cwd(), EVAL_FILENAME)) const evalAwarePartialHost = createEvalAwarePartialHost(state) @@ -59,7 +59,7 @@ export function createReplService (options: CreateReplServiceOptions = {}) { state: options.state ?? new EvalState(join(process.cwd(), EVAL_FILENAME)), setService, evalCode, - nodeReplEval, + nodeEval, evalAwarePartialHost, start, stdin, @@ -77,7 +77,7 @@ export function createReplService (options: CreateReplServiceOptions = {}) { return _eval(service!, state, code) } - function nodeReplEval (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any) { + function nodeEval (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any) { let err: Error | null = null let result: any @@ -216,7 +216,7 @@ function startRepl (replService: ReplService, service: Register, state: EvalStat output: replService.stdout, // Mimicking node's REPL implementation: https://github.com/nodejs/node/blob/168b22ba073ee1cbf8d0bcb4ded7ff3099335d04/lib/internal/repl.js#L28-L30 terminal: (replService.stdout as tty.WriteStream).isTTY && !parseInt(process.env.NODE_NO_READLINE!, 10), - eval: replService.nodeReplEval, + eval: replService.nodeEval, useGlobal: true }) From 354cb103afcceadc4a97e7ad5586ecb0e3a8de42 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Thu, 3 Dec 2020 00:33:36 -0500 Subject: [PATCH 17/18] fix name --- src/index.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index a81254dd3..01b311a01 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -377,7 +377,7 @@ describe('ts-node', function () { const stdin = new PassThrough() const stdout = new PassThrough() const stderr = new PassThrough() - const replService = createReplService({ + const replService = createRepl({ stdin, stdout, stderr From 7f1a7a257886124706a34d9f06a2bcc0954edf5f Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Thu, 3 Dec 2020 01:07:31 -0500 Subject: [PATCH 18/18] Tweak repl API test to match REPL CLI test, allowing it to pass on Windows --- src/index.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 01b311a01..0f6232c69 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -385,17 +385,15 @@ describe('ts-node', function () { const service = create(replService.evalAwarePartialHost) replService.setService(service) replService.start() - stdin.write( - 'const a = 123\n' + - '.type a\n' - ) + stdin.write('\nconst a = 123\n.type a\n') stdin.end() - await promisify(setTimeout)(100) + await promisify(setTimeout)(1e3) stdout.end() stderr.end() expect(await getStream(stderr)).to.equal('') expect(await getStream(stdout)).to.equal( '> \'use strict\'\n' + + '> undefined\n' + '> const a: 123\n' + '> ' )