Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

repl: add completion preview #30907

Closed
wants to merge 12 commits into from
5 changes: 3 additions & 2 deletions doc/api/repl.md
Expand Up @@ -565,8 +565,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`. Always `false` in case `terminal` is
falsy.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that mean the default is true if terminal is truthy and false if terminal is falsy? If that's the case then I think the text can be clarified a little, but that's a nit and not a blocking comment.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's actually not only about the default. If the terminal is false, the preview is always deactivated. I am happy for suggestions to better word this :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe instead of this

Always `false` in case `terminal` is falsy.

...this?:

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
35 changes: 28 additions & 7 deletions lib/internal/readline/utils.js
Expand Up @@ -7,12 +7,13 @@ const {

// Regex used for ansi escape code splitting
// Adopted from https://github.com/chalk/ansi-regex/blob/master/index.js
// License: MIT, authors: @sindresorhus, Qix-, and arjunmehta
// License: MIT, authors: @sindresorhus, Qix-, arjunmehta and LitoMore
// Matches all ansi escape code sequences in a string
/* eslint-disable no-control-regex */
const ansi =
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
/* eslint-enable no-control-regex */
const ansiPattern = '[\\u001B\\u009B][[\\]()#;?]*' +
'(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)' +
'|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))';
const ansi = new RegExp(ansiPattern, 'g');

const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16
const kEscape = '\x1b';

Expand All @@ -30,8 +31,8 @@ function CSI(strings, ...args) {
}

CSI.kEscape = kEscape;
CSI.kClearToBeginning = CSI`1K`;
CSI.kClearToEnd = CSI`0K`;
CSI.kClearToLineBeginning = CSI`1K`;
CSI.kClearToLineEnd = CSI`0K`;
CSI.kClearLine = CSI`2K`;
CSI.kClearScreenDown = CSI`0J`;

Expand Down Expand Up @@ -444,7 +445,27 @@ function* emitKeys(stream) {
}
}

// This runs in O(n log n).
function commonPrefix(strings) {
if (!strings || strings.length === 0) {
return '';
}
if (strings.length === 1) {
return strings[0];
}
const sorted = strings.slice().sort();
const min = sorted[0];
const max = sorted[sorted.length - 1];
for (let i = 0; i < min.length; i++) {
if (min[i] !== max[i]) {
return min.slice(0, i);
}
}
return min;
}

module.exports = {
commonPrefix,
emitKeys,
getStringWidth,
isFullWidthCodePoint,
Expand Down
180 changes: 160 additions & 20 deletions lib/internal/repl/utils.js
Expand Up @@ -28,6 +28,10 @@ const {
moveCursor,
} = require('readline');

const {
commonPrefix
} = require('internal/readline/utils');
BridgeAR marked this conversation as resolved.
Show resolved Hide resolved

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,51 @@ 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;
}

// Do not show previews in case the current line is longer than the column
// width.
// TODO(BridgeAR): Fix me. This should not be necessary. It currently breaks
// the output though. We also have to check for characters that have more
// than a single byte as length. Check Interface.prototype._moveCursor. It
// contains the necessary logic.
if (repl.line.length + repl._prompt.length > repl.columns) {
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 +326,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 +346,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