Skip to content

Commit 657de4a

Browse files
Make ts-node eval public for node REPL consumption (#1121)
* 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 <[email protected]>
1 parent ded513d commit 657de4a

File tree

7 files changed

+411
-250
lines changed

7 files changed

+411
-250
lines changed

development-docs/repl-api.md

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
## How to create your own ts-node powered REPL
2+
3+
- Create ts-node REPL service which includes EvalState
4+
- Create ts-node compiler service using EvalState-aware `readFile` and `fileExists` implementations from REPL
5+
- Bind REPL service to compiler service (chicken-and-egg problem necessitates late binding)
6+
- Either:
7+
- call REPL method start() to start a REPL
8+
- create your own node repl but pass it REPL service's nodeEval() function
9+
10+
```
11+
import * as tsnode from 'ts-node';
12+
const repl = tsnode.createRepl();
13+
const service = tsnode.register({
14+
... options,
15+
...repl.evalAwarePartialHost
16+
});
17+
repl.setService(service);
18+
19+
// Start it
20+
repl.start();
21+
22+
// or
23+
const nodeRepl = require('repl').start({
24+
...options,
25+
eval: repl.nodeEval
26+
});
27+
```

package-lock.json

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"@types/source-map-support": "^0.5.0",
9393
"axios": "^0.19.0",
9494
"chai": "^4.0.1",
95+
"get-stream": "^6.0.0",
9596
"lodash": "^4.17.15",
9697
"mocha": "^6.2.2",
9798
"ntypescript": "^1.201507091536.1",

src/bin.ts

+19-246
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,16 @@
11
#!/usr/bin/env node
22

33
import { join, resolve, dirname } from 'path'
4-
import { start, Recoverable } from 'repl'
54
import { inspect } from 'util'
65
import Module = require('module')
76
import arg = require('arg')
8-
import { diffLines } from 'diff'
9-
import { Script } from 'vm'
10-
import { readFileSync, statSync, realpathSync } from 'fs'
11-
import { homedir } from 'os'
12-
import { VERSION, TSError, parse, Service, register } from './index'
13-
14-
/**
15-
* Eval filename for REPL/debug.
16-
*/
17-
const EVAL_FILENAME = `[eval].ts`
18-
19-
/**
20-
* Eval state management.
21-
*/
22-
class EvalState {
23-
input = ''
24-
output = ''
25-
version = 0
26-
lines = 0
27-
28-
constructor (public path: string) {}
29-
}
7+
import {
8+
EVAL_FILENAME,
9+
EvalState,
10+
createRepl,
11+
ReplService
12+
} from './repl'
13+
import { VERSION, TSError, parse, register } from './index'
3014

3115
/**
3216
* Main `bin` functionality.
@@ -160,6 +144,8 @@ export function main (argv: string[] = process.argv.slice(2), entrypointArgs: Re
160144
/** Unresolved. May point to a symlink, not realpath. May be missing file extension */
161145
const scriptPath = args._.length ? resolve(cwd, args._[0]) : undefined
162146
const state = new EvalState(scriptPath || join(cwd, EVAL_FILENAME))
147+
const replService = createRepl({ state })
148+
const { evalAwarePartialHost } = replService
163149

164150
// Register the TypeScript compiler instance.
165151
const service = register({
@@ -180,29 +166,13 @@ export function main (argv: string[] = process.argv.slice(2), entrypointArgs: Re
180166
ignoreDiagnostics,
181167
compilerOptions,
182168
require: argsRequire,
183-
readFile: code !== undefined
184-
? (path: string) => {
185-
if (path === state.path) return state.input
186-
187-
try {
188-
return readFileSync(path, 'utf8')
189-
} catch (err) {/* Ignore. */}
190-
}
191-
: undefined,
192-
fileExists: code !== undefined
193-
? (path: string) => {
194-
if (path === state.path) return true
195-
196-
try {
197-
const stats = statSync(path)
198-
return stats.isFile() || stats.isFIFO()
199-
} catch (err) {
200-
return false
201-
}
202-
}
203-
: undefined
169+
readFile: code !== undefined ? evalAwarePartialHost.readFile : undefined,
170+
fileExists: code !== undefined ? evalAwarePartialHost.fileExists : undefined
204171
})
205172

173+
// Bind REPL service to ts-node compiler service (chicken-and-egg problem)
174+
replService.setService(service)
175+
206176
// Output project information.
207177
if (version >= 2) {
208178
console.log(`ts-node v${VERSION}`)
@@ -222,19 +192,19 @@ export function main (argv: string[] = process.argv.slice(2), entrypointArgs: Re
222192

223193
// Execute the main contents (either eval, script or piped).
224194
if (code !== undefined && !interactive) {
225-
evalAndExit(service, state, module, code, print)
195+
evalAndExit(replService, module, code, print)
226196
} else {
227197
if (args._.length) {
228198
Module.runMain()
229199
} else {
230200
// Piping of execution _only_ occurs when no other script is specified.
231201
// --interactive flag forces REPL
232202
if (interactive || process.stdin.isTTY) {
233-
startRepl(service, state, code)
203+
replService.start(code)
234204
} else {
235205
let buffer = code || ''
236206
process.stdin.on('data', (chunk: Buffer) => buffer += chunk)
237-
process.stdin.on('end', () => evalAndExit(service, state, module, buffer, print))
207+
process.stdin.on('end', () => evalAndExit(replService, module, buffer, print))
238208
}
239209
}
240210
}
@@ -284,7 +254,7 @@ function getCwd (dir?: string, scriptMode?: boolean, scriptPath?: string) {
284254
/**
285255
* Evaluate a script.
286256
*/
287-
function evalAndExit (service: Service, state: EvalState, module: Module, code: string, isPrinted: boolean) {
257+
function evalAndExit (replService: ReplService, module: Module, code: string, isPrinted: boolean) {
288258
let result: any
289259

290260
;(global as any).__filename = module.filename
@@ -294,7 +264,7 @@ function evalAndExit (service: Service, state: EvalState, module: Module, code:
294264
;(global as any).require = module.require.bind(module)
295265

296266
try {
297-
result = _eval(service, state, code)
267+
result = replService.evalCode(code)
298268
} catch (error) {
299269
if (error instanceof TSError) {
300270
console.error(error)
@@ -309,203 +279,6 @@ function evalAndExit (service: Service, state: EvalState, module: Module, code:
309279
}
310280
}
311281

312-
/**
313-
* Evaluate the code snippet.
314-
*/
315-
function _eval (service: Service, state: EvalState, input: string) {
316-
const lines = state.lines
317-
const isCompletion = !/\n$/.test(input)
318-
const undo = appendEval(state, input)
319-
let output: string
320-
321-
try {
322-
output = service.compile(state.input, state.path, -lines)
323-
} catch (err) {
324-
undo()
325-
throw err
326-
}
327-
328-
// Use `diff` to check for new JavaScript to execute.
329-
const changes = diffLines(state.output, output)
330-
331-
if (isCompletion) {
332-
undo()
333-
} else {
334-
state.output = output
335-
}
336-
337-
return changes.reduce((result, change) => {
338-
return change.added ? exec(change.value, state.path) : result
339-
}, undefined)
340-
}
341-
342-
/**
343-
* Execute some code.
344-
*/
345-
function exec (code: string, filename: string) {
346-
const script = new Script(code, { filename: filename })
347-
348-
return script.runInThisContext()
349-
}
350-
351-
/**
352-
* Start a CLI REPL.
353-
*/
354-
function startRepl (service: Service, state: EvalState, code?: string) {
355-
// Eval incoming code before the REPL starts.
356-
if (code) {
357-
try {
358-
_eval(service, state, `${code}\n`)
359-
} catch (err) {
360-
console.error(err)
361-
process.exit(1)
362-
}
363-
}
364-
365-
const repl = start({
366-
prompt: '> ',
367-
input: process.stdin,
368-
output: process.stdout,
369-
// Mimicking node's REPL implementation: https://github.com/nodejs/node/blob/168b22ba073ee1cbf8d0bcb4ded7ff3099335d04/lib/internal/repl.js#L28-L30
370-
terminal: process.stdout.isTTY && !parseInt(process.env.NODE_NO_READLINE!, 10),
371-
eval: replEval,
372-
useGlobal: true
373-
})
374-
375-
/**
376-
* Eval code from the REPL.
377-
*/
378-
function replEval (code: string, _context: any, _filename: string, callback: (err: Error | null, result?: any) => any) {
379-
let err: Error | null = null
380-
let result: any
381-
382-
// TODO: Figure out how to handle completion here.
383-
if (code === '.scope') {
384-
callback(err)
385-
return
386-
}
387-
388-
try {
389-
result = _eval(service, state, code)
390-
} catch (error) {
391-
if (error instanceof TSError) {
392-
// Support recoverable compilations using >= node 6.
393-
if (Recoverable && isRecoverable(error)) {
394-
err = new Recoverable(error)
395-
} else {
396-
console.error(error)
397-
}
398-
} else {
399-
err = error
400-
}
401-
}
402-
403-
return callback(err, result)
404-
}
405-
406-
// Bookmark the point where we should reset the REPL state.
407-
const resetEval = appendEval(state, '')
408-
409-
function reset () {
410-
resetEval()
411-
412-
// Hard fix for TypeScript forcing `Object.defineProperty(exports, ...)`.
413-
exec('exports = module.exports', state.path)
414-
}
415-
416-
reset()
417-
repl.on('reset', reset)
418-
419-
repl.defineCommand('type', {
420-
help: 'Check the type of a TypeScript identifier',
421-
action: function (identifier: string) {
422-
if (!identifier) {
423-
repl.displayPrompt()
424-
return
425-
}
426-
427-
const undo = appendEval(state, identifier)
428-
const { name, comment } = service.getTypeInfo(state.input, state.path, state.input.length)
429-
430-
undo()
431-
432-
if (name) repl.outputStream.write(`${name}\n`)
433-
if (comment) repl.outputStream.write(`${comment}\n`)
434-
repl.displayPrompt()
435-
}
436-
})
437-
438-
// Set up REPL history when available natively via node.js >= 11.
439-
if (repl.setupHistory) {
440-
const historyPath = process.env.TS_NODE_HISTORY || join(homedir(), '.ts_node_repl_history')
441-
442-
repl.setupHistory(historyPath, err => {
443-
if (!err) return
444-
445-
console.error(err)
446-
process.exit(1)
447-
})
448-
}
449-
}
450-
451-
/**
452-
* Append to the eval instance and return an undo function.
453-
*/
454-
function appendEval (state: EvalState, input: string) {
455-
const undoInput = state.input
456-
const undoVersion = state.version
457-
const undoOutput = state.output
458-
const undoLines = state.lines
459-
460-
// Handle ASI issues with TypeScript re-evaluation.
461-
if (undoInput.charAt(undoInput.length - 1) === '\n' && /^\s*[\/\[(`-]/.test(input) && !/;\s*$/.test(undoInput)) {
462-
state.input = `${state.input.slice(0, -1)};\n`
463-
}
464-
465-
state.input += input
466-
state.lines += lineCount(input)
467-
state.version++
468-
469-
return function () {
470-
state.input = undoInput
471-
state.output = undoOutput
472-
state.version = undoVersion
473-
state.lines = undoLines
474-
}
475-
}
476-
477-
/**
478-
* Count the number of lines.
479-
*/
480-
function lineCount (value: string) {
481-
let count = 0
482-
483-
for (const char of value) {
484-
if (char === '\n') {
485-
count++
486-
}
487-
}
488-
489-
return count
490-
}
491-
492-
const RECOVERY_CODES: Set<number> = new Set([
493-
1003, // "Identifier expected."
494-
1005, // "')' expected."
495-
1109, // "Expression expected."
496-
1126, // "Unexpected end of text."
497-
1160, // "Unterminated template literal."
498-
1161, // "Unterminated regular expression literal."
499-
2355 // "A function whose declared type is neither 'void' nor 'any' must return a value."
500-
])
501-
502-
/**
503-
* Check if a function can recover gracefully.
504-
*/
505-
function isRecoverable (error: TSError) {
506-
return error.diagnosticCodes.every(code => RECOVERY_CODES.has(code))
507-
}
508-
509282
/** Safe `hasOwnProperty` */
510283
function hasOwnProperty (object: any, property: string): boolean {
511284
return Object.prototype.hasOwnProperty.call(object, property)

0 commit comments

Comments
 (0)