Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make ts-node eval public for node REPL consumption #1121

Merged
merged 21 commits into from Dec 3, 2020
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
270 changes: 24 additions & 246 deletions src/bin.ts
@@ -1,32 +1,21 @@
#!/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, 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 {
parse,
register,
TSError,
VERSION
} from './index'
import {
EVAL_FILENAME,
EvalState,
createRepl,
ReplService
} from './repl'

/**
* Main `bin` functionality.
Expand Down Expand Up @@ -160,6 +149,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 +171,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 +197,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 +259,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
Expand All @@ -294,7 +269,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)
Expand All @@ -309,203 +284,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.
*/
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: 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