Skip to content

Commit

Permalink
repl: add completion preview
Browse files Browse the repository at this point in the history
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 <targos@protonmail.com>
Reviewed-By: Rich Trott <rtrott@gmail.com>
  • Loading branch information
BridgeAR authored and targos committed Apr 28, 2020
1 parent 7131de5 commit bf9ff16
Show file tree
Hide file tree
Showing 8 changed files with 262 additions and 81 deletions.
5 changes: 3 additions & 2 deletions doc/api/repl.md
Expand Up @@ -575,8 +575,9 @@ changes:
* `breakEvalOnSigint` {boolean} Stop evaluating the current piece of code when
`SIGINT` is received, such as when `Ctrl+C` is pressed. This cannot be used
together with a custom `eval` function. **Default:** `false`.
* `preview` {boolean} Defines if the repl prints output previews or not.
**Default:** `true`. Always `false` in case `terminal` is falsy.
* `preview` {boolean} Defines if the repl prints autocomplete and output
previews or not. **Default:** `true`. If `terminal` is falsy, then there are
no previews and the value of `preview` has no effect.
* Returns: {repl.REPLServer}

The `repl.start()` method creates and starts a [`repl.REPLServer`][] instance.
Expand Down
170 changes: 150 additions & 20 deletions lib/internal/repl/utils.js
Expand Up @@ -28,6 +28,10 @@ const {
moveCursor,
} = require('readline');

const {
commonPrefix
} = require('internal/readline/utils');

const { inspect } = require('util');

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

let preview = null;
let lastPreview = '';
let inputPreview = null;
let lastInputPreview = '';

let previewCompletionCounter = 0;
let completionPreview = null;

const clearPreview = () => {
if (preview !== null) {
if (inputPreview !== null) {
moveCursor(repl.output, 0, 1);
clearLine(repl.output);
moveCursor(repl.output, 0, -1);
lastPreview = preview;
preview = null;
lastInputPreview = inputPreview;
inputPreview = null;
}
if (completionPreview !== null) {
// Prevent cursor moves if not necessary!
const move = repl.line.length !== repl.cursor;
if (move) {
cursorTo(repl.output, repl._prompt.length + repl.line.length);
}
clearLine(repl.output, 1);
if (move) {
cursorTo(repl.output, repl._prompt.length + repl.cursor);
}
completionPreview = null;
}
};

function showCompletionPreview(line, insertPreview) {
previewCompletionCounter++;

const count = previewCompletionCounter;

repl.completer(line, (error, data) => {
// Tab completion might be async and the result might already be outdated.
if (count !== previewCompletionCounter) {
return;
}

if (error) {
debug('Error while generating completion preview', error);
return;
}

// Result and the text that was completed.
const [rawCompletions, completeOn] = data;

if (!rawCompletions || rawCompletions.length === 0) {
return;
}

// If there is a common prefix to all matches, then apply that portion.
const completions = rawCompletions.filter((e) => e);
const prefix = commonPrefix(completions);

// No common prefix found.
if (prefix.length <= completeOn.length) {
return;
}

const suffix = prefix.slice(completeOn.length);

const totalLength = repl.line.length +
repl._prompt.length +
suffix.length +
(repl.useColors ? 0 : 4);

// TODO(BridgeAR): Fix me. This should not be necessary. See similar
// comment in `showPreview()`.
if (totalLength > repl.columns) {
return;
}

if (insertPreview) {
repl._insertString(suffix);
return;
}

completionPreview = suffix;

const result = repl.useColors ?
`\u001b[90m${suffix}\u001b[39m` :
` // ${suffix}`;

if (repl.line.length !== repl.cursor) {
cursorTo(repl.output, repl._prompt.length + repl.line.length);
}
repl.output.write(result);
cursorTo(repl.output, repl._prompt.length + repl.cursor);
});
}

// This returns a code preview for arbitrary input code.
function getPreviewInput(input, callback) {
function getInputPreview(input, callback) {
// For similar reasons as `defaultEval`, wrap expressions starting with a
// curly brace with parenthesis.
if (input.startsWith('{') && !input.endsWith(';')) {
Expand Down Expand Up @@ -184,23 +267,41 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
}, () => callback(new ERR_INSPECTOR_NOT_AVAILABLE()));
}

const showInputPreview = () => {
const showPreview = () => {
// Prevent duplicated previews after a refresh.
if (preview !== null) {
if (inputPreview !== null) {
return;
}

const line = repl.line.trim();

// Do not preview if the command is buffered or if the line is empty.
if (repl[bufferSymbol] || line === '') {
// Do not preview in case the line only contains whitespace.
if (line === '') {
return;
}

// Add the autocompletion preview.
// TODO(BridgeAR): Trigger the input preview after the completion preview.
// That way it's possible to trigger the input prefix including the
// potential completion suffix. To do so, we also have to change the
// behavior of `enter` and `escape`:
// Enter should automatically add the suffix to the current line as long as
// escape was not pressed. We might even remove the preview in case any
// cursor movement is triggered.
if (typeof repl.completer === 'function') {
const insertPreview = false;
showCompletionPreview(repl.line, insertPreview);
}

// Do not preview if the command is buffered.
if (repl[bufferSymbol]) {
return;
}

getPreviewInput(line, (error, inspected) => {
getInputPreview(line, (error, inspected) => {
// Ignore the output if the value is identical to the current line and the
// former preview is not identical to this preview.
if ((line === inspected && lastPreview !== inspected) ||
if ((line === inspected && lastInputPreview !== inspected) ||
inspected === null) {
return;
}
Expand All @@ -215,7 +316,7 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
return;
}

preview = inspected;
inputPreview = inspected;

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

repl.output.write(`\n${result}`);
moveCursor(repl.output, 0, -1);
cursorTo(repl.output, repl.cursor + repl._prompt.length);
cursorTo(repl.output, repl._prompt.length + repl.cursor);
});
};

// -------------------------------------------------------------------------//
// Replace multiple interface functions. This is required to fully support //
// previews without changing readlines behavior. //
// -------------------------------------------------------------------------//

// Refresh prints the whole screen again and the preview will be removed
// during that procedure. Print the preview again. This also makes sure
// the preview is always correct after resizing the terminal window.
const tmpRefresh = repl._refreshLine.bind(repl);
const originalRefresh = repl._refreshLine.bind(repl);
repl._refreshLine = () => {
preview = null;
tmpRefresh();
showInputPreview();
inputPreview = null;
originalRefresh();
showPreview();
};

let insertCompletionPreview = true;
// Insert the longest common suffix of the current input in case the user
// moves to the right while already being at the current input end.
const originalMoveCursor = repl._moveCursor.bind(repl);
repl._moveCursor = (dx) => {
const currentCursor = repl.cursor;
originalMoveCursor(dx);
if (currentCursor + dx > repl.line.length &&
typeof repl.completer === 'function' &&
insertCompletionPreview) {
const insertPreview = true;
showCompletionPreview(repl.line, insertPreview);
}
};

// This is the only function that interferes with the completion insertion.
// Monkey patch it to prevent inserting the completion when it shouldn't be.
const originalClearLine = repl.clearLine.bind(repl);
repl.clearLine = () => {
insertCompletionPreview = false;
originalClearLine();
insertCompletionPreview = true;
};

return { showInputPreview, clearPreview };
return { showPreview, clearPreview };
}

module.exports = {
Expand Down
8 changes: 6 additions & 2 deletions lib/readline.js
Expand Up @@ -602,8 +602,11 @@ function charLengthLeft(str, i) {
}

function charLengthAt(str, i) {
if (str.length <= i)
return 0;
if (str.length <= i) {
// Pretend to move to the right. This is necessary to autocomplete while
// moving to the right.
return 1;
}
return str.codePointAt(i) >= kUTF16SurrogateThreshold ? 2 : 1;
}

Expand Down Expand Up @@ -958,6 +961,7 @@ Interface.prototype._ttyWrite = function(s, key) {
}
break;

// TODO(BridgeAR): This seems broken?
case 'w': // Delete backwards to a word boundary
case 'backspace':
this._deleteWordLeft();
Expand Down
8 changes: 5 additions & 3 deletions lib/repl.js
Expand Up @@ -821,7 +821,7 @@ function REPLServer(prompt,

const {
clearPreview,
showInputPreview
showPreview
} = setupPreview(
this,
kContextId,
Expand All @@ -832,7 +832,6 @@ function REPLServer(prompt,
// Wrap readline tty to enable editor mode and pausing.
const ttyWrite = self._ttyWrite.bind(self);
self._ttyWrite = (d, key) => {
clearPreview();
key = key || {};
if (paused && !(self.breakEvalOnSigint && key.ctrl && key.name === 'c')) {
pausedBuffer.push(['key', [d, key]]);
Expand All @@ -844,14 +843,17 @@ function REPLServer(prompt,
self.cursor === 0 && self.line.length === 0) {
self.clearLine();
}
clearPreview();
ttyWrite(d, key);
showInputPreview();
showPreview();
return;
}

// Editor mode
if (key.ctrl && !key.shift) {
switch (key.name) {
// TODO(BridgeAR): There should not be a special mode necessary for full
// multiline support.
case 'd': // End editor mode
_turnOffEditorMode(self);
sawCtrlD = true;
Expand Down
9 changes: 6 additions & 3 deletions test/parallel/test-repl-editor.js
Expand Up @@ -5,10 +5,11 @@ const assert = require('assert');
const repl = require('repl');
const ArrayStream = require('../common/arraystream');

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

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

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

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

Expand Down
13 changes: 11 additions & 2 deletions test/parallel/test-repl-multiline.js
Expand Up @@ -23,14 +23,23 @@ function run({ useColors }) {
r.on('exit', common.mustCall(() => {
const actual = output.split('\n');

const firstLine = useColors ?
'\x1B[1G\x1B[0J \x1B[1Gco\x1B[90mn\x1B[39m\x1B[3G\x1B[0Knst ' +
'fo\x1B[90mr\x1B[39m\x1B[9G\x1B[0Ko = {' :
'\x1B[1G\x1B[0J \x1B[1Gco // n\x1B[3G\x1B[0Knst ' +
'fo // r\x1B[9G\x1B[0Ko = {';

// Validate the output, which contains terminal escape codes.
assert.strictEqual(actual.length, 6 + process.features.inspector);
assert.ok(actual[0].endsWith(input[0]));
assert.strictEqual(actual[0], firstLine);
assert.ok(actual[1].includes('... '));
assert.ok(actual[1].endsWith(input[1]));
assert.ok(actual[2].includes('undefined'));
assert.ok(actual[3].endsWith(input[2]));
if (process.features.inspector) {
assert.ok(
actual[3].endsWith(input[2]),
`"${actual[3]}" should end with "${input[2]}"`
);
assert.ok(actual[4].includes(actual[5]));
assert.strictEqual(actual[4].includes('//'), !useColors);
}
Expand Down
8 changes: 4 additions & 4 deletions test/parallel/test-repl-preview.js
Expand Up @@ -72,13 +72,13 @@ async function tests(options) {
'\x1B[36m[Function: foo]\x1B[39m',
'\x1B[1G\x1B[0Jrepl > \x1B[8G'],
['koo', [2, 4], '[Function: koo]',
'koo',
'k\x1B[90moo\x1B[39m\x1B[9G\x1B[0Ko\x1B[90mo\x1B[39m\x1B[10G\x1B[0Ko',
'\x1B[90m[Function: koo]\x1B[39m\x1B[1A\x1B[11G\x1B[1B\x1B[2K\x1B[1A\r',
'\x1B[36m[Function: koo]\x1B[39m',
'\x1B[1G\x1B[0Jrepl > \x1B[8G'],
['a', [1, 2], undefined],
['{ a: true }', [2, 3], '{ a: \x1B[33mtrue\x1B[39m }',
'{ a: true }\r',
'{ a: tru\x1B[90me\x1B[39m\x1B[16G\x1B[0Ke }\r',
'{ a: \x1B[33mtrue\x1B[39m }',
'\x1B[1G\x1B[0Jrepl > \x1B[8G'],
['1n + 2n', [2, 5], '\x1B[33m3n\x1B[39m',
Expand All @@ -88,12 +88,12 @@ async function tests(options) {
'\x1B[33m3n\x1B[39m',
'\x1B[1G\x1B[0Jrepl > \x1B[8G'],
['{ a: true };', [2, 4], '\x1B[33mtrue\x1B[39m',
'{ a: true };',
'{ a: tru\x1B[90me\x1B[39m\x1B[16G\x1B[0Ke };',
'\x1B[90mtrue\x1B[39m\x1B[1A\x1B[20G\x1B[1B\x1B[2K\x1B[1A\r',
'\x1B[33mtrue\x1B[39m',
'\x1B[1G\x1B[0Jrepl > \x1B[8G'],
[' \t { a: true};', [2, 5], '\x1B[33mtrue\x1B[39m',
' \t { a: true}',
' \t { a: tru\x1B[90me\x1B[39m\x1B[19G\x1B[0Ke}',
'\x1B[90m{ a: true }\x1B[39m\x1B[1A\x1B[21G\x1B[1B\x1B[2K\x1B[1A;',
'\x1B[90mtrue\x1B[39m\x1B[1A\x1B[22G\x1B[1B\x1B[2K\x1B[1A\r',
'\x1B[33mtrue\x1B[39m',
Expand Down

0 comments on commit bf9ff16

Please sign in to comment.