Skip to content

Commit b779eb4

Browse files
committed
repl: Add editor mode support
```js > node > .editor // Entering editor mode (^D to finish, ^C to cancel) function test() { console.log('tested!'); } test(); // ^D tested! undefined > ``` PR-URL: #7275 Reviewed-By: James M Snell <[email protected]> Reviewed-By: Evan Lucas <[email protected]>
1 parent 769f63c commit b779eb4

File tree

4 files changed

+206
-3
lines changed

4 files changed

+206
-3
lines changed

doc/api/repl.md

+15
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,21 @@ The following special commands are supported by all REPL instances:
3838
`> .save ./file/to/save.js`
3939
* `.load` - Load a file into the current REPL session.
4040
`> .load ./file/to/load.js`
41+
* `.editor` - Enter editor mode (`<ctrl>-D` to finish, `<ctrl>-C` to cancel)
42+
43+
```js
44+
> .editor
45+
// Entering editor mode (^D to finish, ^C to cancel)
46+
function welcome(name) {
47+
return `Hello ${name}!`;
48+
}
49+
50+
welcome('Node.js User');
51+
52+
// ^D
53+
'Hello Node.js User!'
54+
>
55+
```
4156

4257
The following key combinations in the REPL have these special effects:
4358

lib/repl.js

+114-3
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ function REPLServer(prompt,
223223
self.underscoreAssigned = false;
224224
self.last = undefined;
225225
self.breakEvalOnSigint = !!breakEvalOnSigint;
226+
self.editorMode = false;
226227

227228
self._inTemplateLiteral = false;
228229

@@ -394,7 +395,12 @@ function REPLServer(prompt,
394395
// Figure out which "complete" function to use.
395396
self.completer = (typeof options.completer === 'function')
396397
? options.completer
397-
: complete;
398+
: completer;
399+
400+
function completer(text, cb) {
401+
complete.call(self, text, self.editorMode
402+
? self.completeOnEditorMode(cb) : cb);
403+
}
398404

399405
Interface.call(this, {
400406
input: self.inputStream,
@@ -428,9 +434,11 @@ function REPLServer(prompt,
428434
});
429435

430436
var sawSIGINT = false;
437+
var sawCtrlD = false;
431438
self.on('SIGINT', function() {
432439
var empty = self.line.length === 0;
433440
self.clearLine();
441+
self.turnOffEditorMode();
434442

435443
if (!(self.bufferedCommand && self.bufferedCommand.length > 0) && empty) {
436444
if (sawSIGINT) {
@@ -454,6 +462,11 @@ function REPLServer(prompt,
454462
debug('line %j', cmd);
455463
sawSIGINT = false;
456464

465+
if (self.editorMode) {
466+
self.bufferedCommand += cmd + '\n';
467+
return;
468+
}
469+
457470
// leading whitespaces in template literals should not be trimmed.
458471
if (self._inTemplateLiteral) {
459472
self._inTemplateLiteral = false;
@@ -499,7 +512,8 @@ function REPLServer(prompt,
499512

500513
// If error was SyntaxError and not JSON.parse error
501514
if (e) {
502-
if (e instanceof Recoverable && !self.lineParser.shouldFail) {
515+
if (e instanceof Recoverable && !self.lineParser.shouldFail &&
516+
!sawCtrlD) {
503517
// Start buffering data like that:
504518
// {
505519
// ... x: 1
@@ -515,6 +529,7 @@ function REPLServer(prompt,
515529
// Clear buffer if no SyntaxErrors
516530
self.lineParser.reset();
517531
self.bufferedCommand = '';
532+
sawCtrlD = false;
518533

519534
// If we got any output - print it (if no error)
520535
if (!e &&
@@ -555,9 +570,55 @@ function REPLServer(prompt,
555570
});
556571

557572
self.on('SIGCONT', function() {
558-
self.displayPrompt(true);
573+
if (self.editorMode) {
574+
self.outputStream.write(`${self._initialPrompt}.editor\n`);
575+
self.outputStream.write(
576+
'// Entering editor mode (^D to finish, ^C to cancel)\n');
577+
self.outputStream.write(`${self.bufferedCommand}\n`);
578+
self.prompt(true);
579+
} else {
580+
self.displayPrompt(true);
581+
}
559582
});
560583

584+
// Wrap readline tty to enable editor mode
585+
const ttyWrite = self._ttyWrite.bind(self);
586+
self._ttyWrite = (d, key) => {
587+
if (!self.editorMode || !self.terminal) {
588+
ttyWrite(d, key);
589+
return;
590+
}
591+
592+
// editor mode
593+
if (key.ctrl && !key.shift) {
594+
switch (key.name) {
595+
case 'd': // End editor mode
596+
self.turnOffEditorMode();
597+
sawCtrlD = true;
598+
ttyWrite(d, { name: 'return' });
599+
break;
600+
case 'n': // Override next history item
601+
case 'p': // Override previous history item
602+
break;
603+
default:
604+
ttyWrite(d, key);
605+
}
606+
} else {
607+
switch (key.name) {
608+
case 'up': // Override previous history item
609+
case 'down': // Override next history item
610+
break;
611+
case 'tab':
612+
// prevent double tab behavior
613+
self._previousKey = null;
614+
ttyWrite(d, key);
615+
break;
616+
default:
617+
ttyWrite(d, key);
618+
}
619+
}
620+
};
621+
561622
self.displayPrompt();
562623
}
563624
inherits(REPLServer, Interface);
@@ -680,6 +741,12 @@ REPLServer.prototype.setPrompt = function setPrompt(prompt) {
680741
REPLServer.super_.prototype.setPrompt.call(this, prompt);
681742
};
682743

744+
REPLServer.prototype.turnOffEditorMode = function() {
745+
this.editorMode = false;
746+
this.setPrompt(this._initialPrompt);
747+
};
748+
749+
683750
// A stream to push an array into a REPL
684751
// used in REPLServer.complete
685752
function ArrayStream() {
@@ -987,6 +1054,39 @@ function complete(line, callback) {
9871054
}
9881055
}
9891056

1057+
function longestCommonPrefix(arr = []) {
1058+
const cnt = arr.length;
1059+
if (cnt === 0) return '';
1060+
if (cnt === 1) return arr[0];
1061+
1062+
const first = arr[0];
1063+
// complexity: O(m * n)
1064+
for (let m = 0; m < first.length; m++) {
1065+
const c = first[m];
1066+
for (let n = 1; n < cnt; n++) {
1067+
const entry = arr[n];
1068+
if (m >= entry.length || c !== entry[m]) {
1069+
return first.substring(0, m);
1070+
}
1071+
}
1072+
}
1073+
return first;
1074+
}
1075+
1076+
REPLServer.prototype.completeOnEditorMode = (callback) => (err, results) => {
1077+
if (err) return callback(err);
1078+
1079+
const [completions, completeOn = ''] = results;
1080+
const prefixLength = completeOn.length;
1081+
1082+
if (prefixLength === 0) return callback(null, [[], completeOn]);
1083+
1084+
const isNotEmpty = (v) => v.length > 0;
1085+
const trimCompleteOnPrefix = (v) => v.substring(prefixLength);
1086+
const data = completions.filter(isNotEmpty).map(trimCompleteOnPrefix);
1087+
1088+
callback(null, [[`${completeOn}${longestCommonPrefix(data)}`], completeOn]);
1089+
};
9901090

9911091
/**
9921092
* Used to parse and execute the Node REPL commands.
@@ -1189,6 +1289,17 @@ function defineDefaultCommands(repl) {
11891289
this.displayPrompt();
11901290
}
11911291
});
1292+
1293+
repl.defineCommand('editor', {
1294+
help: 'Entering editor mode (^D to finish, ^C to cancel)',
1295+
action() {
1296+
if (!this.terminal) return;
1297+
this.editorMode = true;
1298+
REPLServer.super_.prototype.setPrompt.call(this, '');
1299+
this.outputStream.write(
1300+
'// Entering editor mode (^D to finish, ^C to cancel)\n');
1301+
}
1302+
});
11921303
}
11931304

11941305
function regexpEscape(s) {

test/parallel/test-repl-.editor.js

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const repl = require('repl');
6+
7+
// \u001b[1G - Moves the cursor to 1st column
8+
// \u001b[0J - Clear screen
9+
// \u001b[3G - Moves the cursor to 3rd column
10+
const terminalCode = '\u001b[1G\u001b[0J> \u001b[3G';
11+
12+
function run(input, output, event) {
13+
const stream = new common.ArrayStream();
14+
let found = '';
15+
16+
stream.write = (msg) => found += msg.replace('\r', '');
17+
18+
const expected = `${terminalCode}.editor\n` +
19+
'// Entering editor mode (^D to finish, ^C to cancel)\n' +
20+
`${input}${output}\n${terminalCode}`;
21+
22+
const replServer = repl.start({
23+
prompt: '> ',
24+
terminal: true,
25+
input: stream,
26+
output: stream,
27+
useColors: false
28+
});
29+
30+
stream.emit('data', '.editor\n');
31+
stream.emit('data', input);
32+
replServer.write('', event);
33+
replServer.close();
34+
assert.strictEqual(found, expected);
35+
}
36+
37+
const tests = [
38+
{
39+
input: '',
40+
output: '\n(To exit, press ^C again or type .exit)',
41+
event: {ctrl: true, name: 'c'}
42+
},
43+
{
44+
input: 'var i = 1;',
45+
output: '',
46+
event: {ctrl: true, name: 'c'}
47+
},
48+
{
49+
input: 'var i = 1;\ni + 3',
50+
output: '\n4',
51+
event: {ctrl: true, name: 'd'}
52+
}
53+
];
54+
55+
tests.forEach(({input, output, event}) => run(input, output, event));

test/parallel/test-repl-tab-complete.js

+22
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,25 @@ testCustomCompleterAsyncMode.complete('a', common.mustCall((error, data) => {
348348
'a'
349349
]);
350350
}));
351+
352+
// tab completion in editor mode
353+
const editorStream = new common.ArrayStream();
354+
const editor = repl.start({
355+
stream: editorStream,
356+
terminal: true,
357+
useColors: false
358+
});
359+
360+
editorStream.run(['.clear']);
361+
editorStream.run(['.editor']);
362+
363+
editor.completer('co', common.mustCall((error, data) => {
364+
assert.deepStrictEqual(data, [['con'], 'co']);
365+
}));
366+
367+
editorStream.run(['.clear']);
368+
editorStream.run(['.editor']);
369+
370+
editor.completer('var log = console.l', common.mustCall((error, data) => {
371+
assert.deepStrictEqual(data, [['console.log'], 'console.l']);
372+
}));

0 commit comments

Comments
 (0)