Skip to content

Commit

Permalink
Make ts-node eval public for node REPL consumption (#1121)
Browse files Browse the repository at this point in the history
* Add createReplEval function

* Add a test

* Fix a couple of typos

* Actually test the evaluator

* Try removing spec

* Use service creation pattern

* Move REPL code to its own file

* - 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

* Expand ReplService API; use new API in bin.ts, which further decouples bin.ts from repl.ts

* Add brief, internal docs for REPL API

* Add support for DI of alternative stdio streams into REPL

* export REPL from index.ts

* remove unnecessary export

* Add test for new REPL API

* API surface, naming, docs tweaks

* tweak identifiers

* fix name

* Tweak repl API test to match REPL CLI test, allowing it to pass on Windows

Co-authored-by: Andrew Bradley <cspotcode@gmail.com>
  • Loading branch information
MarcManiez and cspotcode committed Dec 3, 2020
1 parent ded513d commit 657de4a
Show file tree
Hide file tree
Showing 7 changed files with 411 additions and 250 deletions.
27 changes: 27 additions & 0 deletions 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 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 nodeEval() function

```
import * as tsnode from 'ts-node';
const repl = tsnode.createRepl();
const service = tsnode.register({
... options,
...repl.evalAwarePartialHost
});
repl.setService(service);
// Start it
repl.start();
// or
const nodeRepl = require('repl').start({
...options,
eval: repl.nodeEval
});
```
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Expand Up @@ -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",
Expand Down
265 changes: 19 additions & 246 deletions src/bin.ts
@@ -1,32 +1,16 @@
#!/usr/bin/env node

import { join, resolve, dirname } from 'path'
import { start, Recoverable } 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, Service, 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_FILENAME,
EvalState,
createRepl,
ReplService
} from './repl'
import { VERSION, TSError, parse, register } from './index'

/**
* Main `bin` functionality.
Expand Down Expand Up @@ -160,6 +144,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 = createRepl({ state })
const { evalAwarePartialHost } = replService

// Register the TypeScript compiler instance.
const service = register({
Expand All @@ -180,29 +166,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 ? evalAwarePartialHost.readFile : undefined,
fileExists: code !== undefined ? evalAwarePartialHost.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}`)
Expand All @@ -222,19 +192,19 @@ 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()
} else {
// 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))
}
}
}
Expand Down Expand Up @@ -284,7 +254,7 @@ function getCwd (dir?: string, scriptMode?: boolean, scriptPath?: string) {
/**
* Evaluate a script.
*/
function evalAndExit (service: Service, 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
Expand All @@ -294,7 +264,7 @@ function evalAndExit (service: Service, 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)
Expand All @@ -309,203 +279,6 @@ function evalAndExit (service: Service, state: EvalState, module: Module, code:
}
}

/**
* Evaluate the code snippet.
*/
function _eval (service: Service, 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.
*/
function startRepl (service: Service, 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: replEval,
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, '')

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)
})
}
}

/**
* 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<number> = 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)
Expand Down

0 comments on commit 657de4a

Please sign in to comment.