Skip to content

Commit bf9ff16

Browse files
BridgeARtargos
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 7131de5 commit bf9ff16

File tree

8 files changed

+262
-81
lines changed

8 files changed

+262
-81
lines changed

doc/api/repl.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -575,8 +575,9 @@ changes:
575575
* `breakEvalOnSigint` {boolean} Stop evaluating the current piece of code when
576576
`SIGINT` is received, such as when `Ctrl+C` is pressed. This cannot be used
577577
together with a custom `eval` function. **Default:** `false`.
578-
* `preview` {boolean} Defines if the repl prints output previews or not.
579-
**Default:** `true`. Always `false` in case `terminal` is falsy.
578+
* `preview` {boolean} Defines if the repl prints autocomplete and output
579+
previews or not. **Default:** `true`. If `terminal` is falsy, then there are
580+
no previews and the value of `preview` has no effect.
580581
* Returns: {repl.REPLServer}
581582

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

lib/internal/repl/utils.js

Lines changed: 150 additions & 20 deletions
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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -602,8 +602,11 @@ function charLengthLeft(str, i) {
602602
}
603603

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

@@ -958,6 +961,7 @@ Interface.prototype._ttyWrite = function(s, key) {
958961
}
959962
break;
960963

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

lib/repl.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -821,7 +821,7 @@ function REPLServer(prompt,
821821

822822
const {
823823
clearPreview,
824-
showInputPreview
824+
showPreview
825825
} = setupPreview(
826826
this,
827827
kContextId,
@@ -832,7 +832,6 @@ function REPLServer(prompt,
832832
// Wrap readline tty to enable editor mode and pausing.
833833
const ttyWrite = self._ttyWrite.bind(self);
834834
self._ttyWrite = (d, key) => {
835-
clearPreview();
836835
key = key || {};
837836
if (paused && !(self.breakEvalOnSigint && key.ctrl && key.name === 'c')) {
838837
pausedBuffer.push(['key', [d, key]]);
@@ -844,14 +843,17 @@ function REPLServer(prompt,
844843
self.cursor === 0 && self.line.length === 0) {
845844
self.clearLine();
846845
}
846+
clearPreview();
847847
ttyWrite(d, key);
848-
showInputPreview();
848+
showPreview();
849849
return;
850850
}
851851

852852
// Editor mode
853853
if (key.ctrl && !key.shift) {
854854
switch (key.name) {
855+
// TODO(BridgeAR): There should not be a special mode necessary for full
856+
// multiline support.
855857
case 'd': // End editor mode
856858
_turnOffEditorMode(self);
857859
sawCtrlD = true;

test/parallel/test-repl-editor.js

Lines changed: 6 additions & 3 deletions
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

Lines changed: 11 additions & 2 deletions
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

Lines changed: 4 additions & 4 deletions
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)