Skip to content

Commit 6a3e79f

Browse files
BridgeARMylesBorins
authored andcommitted
repl: add completion preview
This improves the already existing preview functionality by also checking for the input completion. In case there's only a single completion, it will automatically be visible to the user in grey. If colors are deactivated, it will be visible as comment. This also changes some keys by automatically accepting the preview by moving the cursor behind the current input end. PR-URL: #30907 Reviewed-By: Michaël Zasso <[email protected]> Reviewed-By: Rich Trott <[email protected]>
1 parent 1a8f828 commit 6a3e79f

8 files changed

+262
-81
lines changed

doc/api/repl.md

+3-2
Original file line numberDiff line numberDiff line change
@@ -565,8 +565,9 @@ changes:
565565
* `breakEvalOnSigint` {boolean} Stop evaluating the current piece of code when
566566
`SIGINT` is received, such as when `Ctrl+C` is pressed. This cannot be used
567567
together with a custom `eval` function. **Default:** `false`.
568-
* `preview` {boolean} Defines if the repl prints output previews or not.
569-
**Default:** `true`. Always `false` in case `terminal` is falsy.
568+
* `preview` {boolean} Defines if the repl prints autocomplete and output
569+
previews or not. **Default:** `true`. If `terminal` is falsy, then there are
570+
no previews and the value of `preview` has no effect.
570571
* Returns: {repl.REPLServer}
571572

572573
The `repl.start()` method creates and starts a [`repl.REPLServer`][] instance.

lib/internal/repl/utils.js

+150-20
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ const {
2828
moveCursor,
2929
} = require('readline');
3030

31+
const {
32+
commonPrefix
33+
} = require('internal/readline/utils');
34+
3135
const { inspect } = require('util');
3236

3337
const debug = require('internal/util/debuglog').debuglog('repl');
@@ -119,24 +123,103 @@ function isRecoverableError(e, code) {
119123
function setupPreview(repl, contextSymbol, bufferSymbol, active) {
120124
// Simple terminals can't handle previews.
121125
if (process.env.TERM === 'dumb' || !active) {
122-
return { showInputPreview() {}, clearPreview() {} };
126+
return { showPreview() {}, clearPreview() {} };
123127
}
124128

125-
let preview = null;
126-
let lastPreview = '';
129+
let inputPreview = null;
130+
let lastInputPreview = '';
131+
132+
let previewCompletionCounter = 0;
133+
let completionPreview = null;
127134

128135
const clearPreview = () => {
129-
if (preview !== null) {
136+
if (inputPreview !== null) {
130137
moveCursor(repl.output, 0, 1);
131138
clearLine(repl.output);
132139
moveCursor(repl.output, 0, -1);
133-
lastPreview = preview;
134-
preview = null;
140+
lastInputPreview = inputPreview;
141+
inputPreview = null;
142+
}
143+
if (completionPreview !== null) {
144+
// Prevent cursor moves if not necessary!
145+
const move = repl.line.length !== repl.cursor;
146+
if (move) {
147+
cursorTo(repl.output, repl._prompt.length + repl.line.length);
148+
}
149+
clearLine(repl.output, 1);
150+
if (move) {
151+
cursorTo(repl.output, repl._prompt.length + repl.cursor);
152+
}
153+
completionPreview = null;
135154
}
136155
};
137156

157+
function showCompletionPreview(line, insertPreview) {
158+
previewCompletionCounter++;
159+
160+
const count = previewCompletionCounter;
161+
162+
repl.completer(line, (error, data) => {
163+
// Tab completion might be async and the result might already be outdated.
164+
if (count !== previewCompletionCounter) {
165+
return;
166+
}
167+
168+
if (error) {
169+
debug('Error while generating completion preview', error);
170+
return;
171+
}
172+
173+
// Result and the text that was completed.
174+
const [rawCompletions, completeOn] = data;
175+
176+
if (!rawCompletions || rawCompletions.length === 0) {
177+
return;
178+
}
179+
180+
// If there is a common prefix to all matches, then apply that portion.
181+
const completions = rawCompletions.filter((e) => e);
182+
const prefix = commonPrefix(completions);
183+
184+
// No common prefix found.
185+
if (prefix.length <= completeOn.length) {
186+
return;
187+
}
188+
189+
const suffix = prefix.slice(completeOn.length);
190+
191+
const totalLength = repl.line.length +
192+
repl._prompt.length +
193+
suffix.length +
194+
(repl.useColors ? 0 : 4);
195+
196+
// TODO(BridgeAR): Fix me. This should not be necessary. See similar
197+
// comment in `showPreview()`.
198+
if (totalLength > repl.columns) {
199+
return;
200+
}
201+
202+
if (insertPreview) {
203+
repl._insertString(suffix);
204+
return;
205+
}
206+
207+
completionPreview = suffix;
208+
209+
const result = repl.useColors ?
210+
`\u001b[90m${suffix}\u001b[39m` :
211+
` // ${suffix}`;
212+
213+
if (repl.line.length !== repl.cursor) {
214+
cursorTo(repl.output, repl._prompt.length + repl.line.length);
215+
}
216+
repl.output.write(result);
217+
cursorTo(repl.output, repl._prompt.length + repl.cursor);
218+
});
219+
}
220+
138221
// This returns a code preview for arbitrary input code.
139-
function getPreviewInput(input, callback) {
222+
function getInputPreview(input, callback) {
140223
// For similar reasons as `defaultEval`, wrap expressions starting with a
141224
// curly brace with parenthesis.
142225
if (input.startsWith('{') && !input.endsWith(';')) {
@@ -184,23 +267,41 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
184267
}, () => callback(new ERR_INSPECTOR_NOT_AVAILABLE()));
185268
}
186269

187-
const showInputPreview = () => {
270+
const showPreview = () => {
188271
// Prevent duplicated previews after a refresh.
189-
if (preview !== null) {
272+
if (inputPreview !== null) {
190273
return;
191274
}
192275

193276
const line = repl.line.trim();
194277

195-
// Do not preview if the command is buffered or if the line is empty.
196-
if (repl[bufferSymbol] || line === '') {
278+
// Do not preview in case the line only contains whitespace.
279+
if (line === '') {
280+
return;
281+
}
282+
283+
// Add the autocompletion preview.
284+
// TODO(BridgeAR): Trigger the input preview after the completion preview.
285+
// That way it's possible to trigger the input prefix including the
286+
// potential completion suffix. To do so, we also have to change the
287+
// behavior of `enter` and `escape`:
288+
// Enter should automatically add the suffix to the current line as long as
289+
// escape was not pressed. We might even remove the preview in case any
290+
// cursor movement is triggered.
291+
if (typeof repl.completer === 'function') {
292+
const insertPreview = false;
293+
showCompletionPreview(repl.line, insertPreview);
294+
}
295+
296+
// Do not preview if the command is buffered.
297+
if (repl[bufferSymbol]) {
197298
return;
198299
}
199300

200-
getPreviewInput(line, (error, inspected) => {
301+
getInputPreview(line, (error, inspected) => {
201302
// Ignore the output if the value is identical to the current line and the
202303
// former preview is not identical to this preview.
203-
if ((line === inspected && lastPreview !== inspected) ||
304+
if ((line === inspected && lastInputPreview !== inspected) ||
204305
inspected === null) {
205306
return;
206307
}
@@ -215,7 +316,7 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
215316
return;
216317
}
217318

218-
preview = inspected;
319+
inputPreview = inspected;
219320

220321
// Limit the output to maximum 250 characters. Otherwise it becomes a)
221322
// difficult to read and b) non terminal REPLs would visualize the whole
@@ -235,21 +336,50 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
235336

236337
repl.output.write(`\n${result}`);
237338
moveCursor(repl.output, 0, -1);
238-
cursorTo(repl.output, repl.cursor + repl._prompt.length);
339+
cursorTo(repl.output, repl._prompt.length + repl.cursor);
239340
});
240341
};
241342

343+
// -------------------------------------------------------------------------//
344+
// Replace multiple interface functions. This is required to fully support //
345+
// previews without changing readlines behavior. //
346+
// -------------------------------------------------------------------------//
347+
242348
// Refresh prints the whole screen again and the preview will be removed
243349
// during that procedure. Print the preview again. This also makes sure
244350
// the preview is always correct after resizing the terminal window.
245-
const tmpRefresh = repl._refreshLine.bind(repl);
351+
const originalRefresh = repl._refreshLine.bind(repl);
246352
repl._refreshLine = () => {
247-
preview = null;
248-
tmpRefresh();
249-
showInputPreview();
353+
inputPreview = null;
354+
originalRefresh();
355+
showPreview();
356+
};
357+
358+
let insertCompletionPreview = true;
359+
// Insert the longest common suffix of the current input in case the user
360+
// moves to the right while already being at the current input end.
361+
const originalMoveCursor = repl._moveCursor.bind(repl);
362+
repl._moveCursor = (dx) => {
363+
const currentCursor = repl.cursor;
364+
originalMoveCursor(dx);
365+
if (currentCursor + dx > repl.line.length &&
366+
typeof repl.completer === 'function' &&
367+
insertCompletionPreview) {
368+
const insertPreview = true;
369+
showCompletionPreview(repl.line, insertPreview);
370+
}
371+
};
372+
373+
// This is the only function that interferes with the completion insertion.
374+
// Monkey patch it to prevent inserting the completion when it shouldn't be.
375+
const originalClearLine = repl.clearLine.bind(repl);
376+
repl.clearLine = () => {
377+
insertCompletionPreview = false;
378+
originalClearLine();
379+
insertCompletionPreview = true;
250380
};
251381

252-
return { showInputPreview, clearPreview };
382+
return { showPreview, clearPreview };
253383
}
254384

255385
module.exports = {

lib/readline.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -601,8 +601,11 @@ function charLengthLeft(str, i) {
601601
}
602602

603603
function charLengthAt(str, i) {
604-
if (str.length <= i)
605-
return 0;
604+
if (str.length <= i) {
605+
// Pretend to move to the right. This is necessary to autocomplete while
606+
// moving to the right.
607+
return 1;
608+
}
606609
return str.codePointAt(i) >= kUTF16SurrogateThreshold ? 2 : 1;
607610
}
608611

@@ -956,6 +959,7 @@ Interface.prototype._ttyWrite = function(s, key) {
956959
}
957960
break;
958961

962+
// TODO(BridgeAR): This seems broken?
959963
case 'w': // Delete backwards to a word boundary
960964
case 'backspace':
961965
this._deleteWordLeft();

lib/repl.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -810,7 +810,7 @@ function REPLServer(prompt,
810810

811811
const {
812812
clearPreview,
813-
showInputPreview
813+
showPreview
814814
} = setupPreview(
815815
this,
816816
kContextId,
@@ -821,7 +821,6 @@ function REPLServer(prompt,
821821
// Wrap readline tty to enable editor mode and pausing.
822822
const ttyWrite = self._ttyWrite.bind(self);
823823
self._ttyWrite = (d, key) => {
824-
clearPreview();
825824
key = key || {};
826825
if (paused && !(self.breakEvalOnSigint && key.ctrl && key.name === 'c')) {
827826
pausedBuffer.push(['key', [d, key]]);
@@ -833,14 +832,17 @@ function REPLServer(prompt,
833832
self.cursor === 0 && self.line.length === 0) {
834833
self.clearLine();
835834
}
835+
clearPreview();
836836
ttyWrite(d, key);
837-
showInputPreview();
837+
showPreview();
838838
return;
839839
}
840840

841841
// Editor mode
842842
if (key.ctrl && !key.shift) {
843843
switch (key.name) {
844+
// TODO(BridgeAR): There should not be a special mode necessary for full
845+
// multiline support.
844846
case 'd': // End editor mode
845847
_turnOffEditorMode(self);
846848
sawCtrlD = true;

test/parallel/test-repl-editor.js

+6-3
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ const assert = require('assert');
55
const repl = require('repl');
66
const ArrayStream = require('../common/arraystream');
77

8-
// \u001b[1G - Moves the cursor to 1st column
8+
// \u001b[nG - Moves the cursor to n st column
99
// \u001b[0J - Clear screen
10-
// \u001b[3G - Moves the cursor to 3rd column
10+
// \u001b[0K - Clear to line end
1111
const terminalCode = '\u001b[1G\u001b[0J> \u001b[3G';
12+
const previewCode = (str, n) => ` // ${str}\x1B[${n}G\x1B[0K`;
1213
const terminalCodeRegex = new RegExp(terminalCode.replace(/\[/g, '\\['), 'g');
1314

1415
function run({ input, output, event, checkTerminalCodes = true }) {
@@ -17,7 +18,9 @@ function run({ input, output, event, checkTerminalCodes = true }) {
1718

1819
stream.write = (msg) => found += msg.replace('\r', '');
1920

20-
let expected = `${terminalCode}.editor\n` +
21+
let expected = `${terminalCode}.ed${previewCode('itor', 6)}i` +
22+
`${previewCode('tor', 7)}t${previewCode('or', 8)}o` +
23+
`${previewCode('r', 9)}r\n` +
2124
'// Entering editor mode (^D to finish, ^C to cancel)\n' +
2225
`${input}${output}\n${terminalCode}`;
2326

test/parallel/test-repl-multiline.js

+11-2
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,23 @@ function run({ useColors }) {
2323
r.on('exit', common.mustCall(() => {
2424
const actual = output.split('\n');
2525

26+
const firstLine = useColors ?
27+
'\x1B[1G\x1B[0J \x1B[1Gco\x1B[90mn\x1B[39m\x1B[3G\x1B[0Knst ' +
28+
'fo\x1B[90mr\x1B[39m\x1B[9G\x1B[0Ko = {' :
29+
'\x1B[1G\x1B[0J \x1B[1Gco // n\x1B[3G\x1B[0Knst ' +
30+
'fo // r\x1B[9G\x1B[0Ko = {';
31+
2632
// Validate the output, which contains terminal escape codes.
2733
assert.strictEqual(actual.length, 6 + process.features.inspector);
28-
assert.ok(actual[0].endsWith(input[0]));
34+
assert.strictEqual(actual[0], firstLine);
2935
assert.ok(actual[1].includes('... '));
3036
assert.ok(actual[1].endsWith(input[1]));
3137
assert.ok(actual[2].includes('undefined'));
32-
assert.ok(actual[3].endsWith(input[2]));
3338
if (process.features.inspector) {
39+
assert.ok(
40+
actual[3].endsWith(input[2]),
41+
`"${actual[3]}" should end with "${input[2]}"`
42+
);
3443
assert.ok(actual[4].includes(actual[5]));
3544
assert.strictEqual(actual[4].includes('//'), !useColors);
3645
}

test/parallel/test-repl-preview.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,13 @@ async function tests(options) {
7272
'\x1B[36m[Function: foo]\x1B[39m',
7373
'\x1B[1G\x1B[0Jrepl > \x1B[8G'],
7474
['koo', [2, 4], '[Function: koo]',
75-
'koo',
75+
'k\x1B[90moo\x1B[39m\x1B[9G\x1B[0Ko\x1B[90mo\x1B[39m\x1B[10G\x1B[0Ko',
7676
'\x1B[90m[Function: koo]\x1B[39m\x1B[1A\x1B[11G\x1B[1B\x1B[2K\x1B[1A\r',
7777
'\x1B[36m[Function: koo]\x1B[39m',
7878
'\x1B[1G\x1B[0Jrepl > \x1B[8G'],
7979
['a', [1, 2], undefined],
8080
['{ a: true }', [2, 3], '{ a: \x1B[33mtrue\x1B[39m }',
81-
'{ a: true }\r',
81+
'{ a: tru\x1B[90me\x1B[39m\x1B[16G\x1B[0Ke }\r',
8282
'{ a: \x1B[33mtrue\x1B[39m }',
8383
'\x1B[1G\x1B[0Jrepl > \x1B[8G'],
8484
['1n + 2n', [2, 5], '\x1B[33m3n\x1B[39m',
@@ -88,12 +88,12 @@ async function tests(options) {
8888
'\x1B[33m3n\x1B[39m',
8989
'\x1B[1G\x1B[0Jrepl > \x1B[8G'],
9090
['{ a: true };', [2, 4], '\x1B[33mtrue\x1B[39m',
91-
'{ a: true };',
91+
'{ a: tru\x1B[90me\x1B[39m\x1B[16G\x1B[0Ke };',
9292
'\x1B[90mtrue\x1B[39m\x1B[1A\x1B[20G\x1B[1B\x1B[2K\x1B[1A\r',
9393
'\x1B[33mtrue\x1B[39m',
9494
'\x1B[1G\x1B[0Jrepl > \x1B[8G'],
9595
[' \t { a: true};', [2, 5], '\x1B[33mtrue\x1B[39m',
96-
' \t { a: true}',
96+
' \t { a: tru\x1B[90me\x1B[39m\x1B[19G\x1B[0Ke}',
9797
'\x1B[90m{ a: true }\x1B[39m\x1B[1A\x1B[21G\x1B[1B\x1B[2K\x1B[1A;',
9898
'\x1B[90mtrue\x1B[39m\x1B[1A\x1B[22G\x1B[1B\x1B[2K\x1B[1A\r',
9999
'\x1B[33mtrue\x1B[39m',

0 commit comments

Comments
 (0)