diff --git a/doc/api/repl.md b/doc/api/repl.md index c4cc092ab2..d8b86f9ef6 100644 --- a/doc/api/repl.md +++ b/doc/api/repl.md @@ -38,6 +38,21 @@ The following special commands are supported by all REPL instances: `> .save ./file/to/save.js` * `.load` - Load a file into the current REPL session. `> .load ./file/to/load.js` +* `.editor` - Enter editor mode (`-D` to finish, `-C` to cancel) + +```js +> .editor +// Entering editor mode (^D to finish, ^C to cancel) +function welcome(name) { + return `Hello ${name}!`; +} + +welcome('Node.js User'); + +// ^D +'Hello Node.js User!' +> +``` The following key combinations in the REPL have these special effects: diff --git a/lib/repl.js b/lib/repl.js index d6eedffa3c..dfe85c6fcf 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -223,6 +223,7 @@ function REPLServer(prompt, self.underscoreAssigned = false; self.last = undefined; self.breakEvalOnSigint = !!breakEvalOnSigint; + self.editorMode = false; self._inTemplateLiteral = false; @@ -394,7 +395,12 @@ function REPLServer(prompt, // Figure out which "complete" function to use. self.completer = (typeof options.completer === 'function') ? options.completer - : complete; + : completer; + + function completer(text, cb) { + complete.call(self, text, self.editorMode + ? self.completeOnEditorMode(cb) : cb); + } Interface.call(this, { input: self.inputStream, @@ -428,9 +434,11 @@ function REPLServer(prompt, }); var sawSIGINT = false; + var sawCtrlD = false; self.on('SIGINT', function() { var empty = self.line.length === 0; self.clearLine(); + self.turnOffEditorMode(); if (!(self.bufferedCommand && self.bufferedCommand.length > 0) && empty) { if (sawSIGINT) { @@ -454,6 +462,11 @@ function REPLServer(prompt, debug('line %j', cmd); sawSIGINT = false; + if (self.editorMode) { + self.bufferedCommand += cmd + '\n'; + return; + } + // leading whitespaces in template literals should not be trimmed. if (self._inTemplateLiteral) { self._inTemplateLiteral = false; @@ -499,7 +512,8 @@ function REPLServer(prompt, // If error was SyntaxError and not JSON.parse error if (e) { - if (e instanceof Recoverable && !self.lineParser.shouldFail) { + if (e instanceof Recoverable && !self.lineParser.shouldFail && + !sawCtrlD) { // Start buffering data like that: // { // ... x: 1 @@ -515,6 +529,7 @@ function REPLServer(prompt, // Clear buffer if no SyntaxErrors self.lineParser.reset(); self.bufferedCommand = ''; + sawCtrlD = false; // If we got any output - print it (if no error) if (!e && @@ -555,9 +570,55 @@ function REPLServer(prompt, }); self.on('SIGCONT', function() { - self.displayPrompt(true); + if (self.editorMode) { + self.outputStream.write(`${self._initialPrompt}.editor\n`); + self.outputStream.write( + '// Entering editor mode (^D to finish, ^C to cancel)\n'); + self.outputStream.write(`${self.bufferedCommand}\n`); + self.prompt(true); + } else { + self.displayPrompt(true); + } }); + // Wrap readline tty to enable editor mode + const ttyWrite = self._ttyWrite.bind(self); + self._ttyWrite = (d, key) => { + if (!self.editorMode || !self.terminal) { + ttyWrite(d, key); + return; + } + + // editor mode + if (key.ctrl && !key.shift) { + switch (key.name) { + case 'd': // End editor mode + self.turnOffEditorMode(); + sawCtrlD = true; + ttyWrite(d, { name: 'return' }); + break; + case 'n': // Override next history item + case 'p': // Override previous history item + break; + default: + ttyWrite(d, key); + } + } else { + switch (key.name) { + case 'up': // Override previous history item + case 'down': // Override next history item + break; + case 'tab': + // prevent double tab behavior + self._previousKey = null; + ttyWrite(d, key); + break; + default: + ttyWrite(d, key); + } + } + }; + self.displayPrompt(); } inherits(REPLServer, Interface); @@ -680,6 +741,12 @@ REPLServer.prototype.setPrompt = function setPrompt(prompt) { REPLServer.super_.prototype.setPrompt.call(this, prompt); }; +REPLServer.prototype.turnOffEditorMode = function() { + this.editorMode = false; + this.setPrompt(this._initialPrompt); +}; + + // A stream to push an array into a REPL // used in REPLServer.complete function ArrayStream() { @@ -987,6 +1054,39 @@ function complete(line, callback) { } } +function longestCommonPrefix(arr = []) { + const cnt = arr.length; + if (cnt === 0) return ''; + if (cnt === 1) return arr[0]; + + const first = arr[0]; + // complexity: O(m * n) + for (let m = 0; m < first.length; m++) { + const c = first[m]; + for (let n = 1; n < cnt; n++) { + const entry = arr[n]; + if (m >= entry.length || c !== entry[m]) { + return first.substring(0, m); + } + } + } + return first; +} + +REPLServer.prototype.completeOnEditorMode = (callback) => (err, results) => { + if (err) return callback(err); + + const [completions, completeOn = ''] = results; + const prefixLength = completeOn.length; + + if (prefixLength === 0) return callback(null, [[], completeOn]); + + const isNotEmpty = (v) => v.length > 0; + const trimCompleteOnPrefix = (v) => v.substring(prefixLength); + const data = completions.filter(isNotEmpty).map(trimCompleteOnPrefix); + + callback(null, [[`${completeOn}${longestCommonPrefix(data)}`], completeOn]); +}; /** * Used to parse and execute the Node REPL commands. @@ -1189,6 +1289,17 @@ function defineDefaultCommands(repl) { this.displayPrompt(); } }); + + repl.defineCommand('editor', { + help: 'Entering editor mode (^D to finish, ^C to cancel)', + action() { + if (!this.terminal) return; + this.editorMode = true; + REPLServer.super_.prototype.setPrompt.call(this, ''); + this.outputStream.write( + '// Entering editor mode (^D to finish, ^C to cancel)\n'); + } + }); } function regexpEscape(s) { diff --git a/test/parallel/test-repl-.editor.js b/test/parallel/test-repl-.editor.js new file mode 100644 index 0000000000..15765ad517 --- /dev/null +++ b/test/parallel/test-repl-.editor.js @@ -0,0 +1,55 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const repl = require('repl'); + +// \u001b[1G - Moves the cursor to 1st column +// \u001b[0J - Clear screen +// \u001b[3G - Moves the cursor to 3rd column +const terminalCode = '\u001b[1G\u001b[0J> \u001b[3G'; + +function run(input, output, event) { + const stream = new common.ArrayStream(); + let found = ''; + + stream.write = (msg) => found += msg.replace('\r', ''); + + const expected = `${terminalCode}.editor\n` + + '// Entering editor mode (^D to finish, ^C to cancel)\n' + + `${input}${output}\n${terminalCode}`; + + const replServer = repl.start({ + prompt: '> ', + terminal: true, + input: stream, + output: stream, + useColors: false + }); + + stream.emit('data', '.editor\n'); + stream.emit('data', input); + replServer.write('', event); + replServer.close(); + assert.strictEqual(found, expected); +} + +const tests = [ + { + input: '', + output: '\n(To exit, press ^C again or type .exit)', + event: {ctrl: true, name: 'c'} + }, + { + input: 'var i = 1;', + output: '', + event: {ctrl: true, name: 'c'} + }, + { + input: 'var i = 1;\ni + 3', + output: '\n4', + event: {ctrl: true, name: 'd'} + } +]; + +tests.forEach(({input, output, event}) => run(input, output, event)); diff --git a/test/parallel/test-repl-tab-complete.js b/test/parallel/test-repl-tab-complete.js index 73a3fd148b..4ff4371875 100644 --- a/test/parallel/test-repl-tab-complete.js +++ b/test/parallel/test-repl-tab-complete.js @@ -348,3 +348,25 @@ testCustomCompleterAsyncMode.complete('a', common.mustCall((error, data) => { 'a' ]); })); + +// tab completion in editor mode +const editorStream = new common.ArrayStream(); +const editor = repl.start({ + stream: editorStream, + terminal: true, + useColors: false +}); + +editorStream.run(['.clear']); +editorStream.run(['.editor']); + +editor.completer('co', common.mustCall((error, data) => { + assert.deepStrictEqual(data, [['con'], 'co']); +})); + +editorStream.run(['.clear']); +editorStream.run(['.editor']); + +editor.completer('var log = console.l', common.mustCall((error, data) => { + assert.deepStrictEqual(data, [['console.log'], 'console.l']); +}));