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 reverse search #31006

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
27 changes: 24 additions & 3 deletions doc/api/repl.md
Expand Up @@ -21,9 +21,11 @@ result. Input and output may be from `stdin` and `stdout`, respectively, or may
be connected to any Node.js [stream][].

Instances of [`repl.REPLServer`][] support automatic completion of inputs,
simplistic Emacs-style line editing, multi-line inputs, ANSI-styled output,
saving and restoring current REPL session state, error recovery, and
customizable evaluation functions.
completion preview, simplistic Emacs-style line editing, multi-line inputs,
[ZSH][] like reverse-i-search, ANSI-styled output, saving and restoring current
REPL session state, error recovery, and customizable evaluation functions.
Terminals that do not support ANSI-styles and Emacs-style line editing
automatically fall back to a limited feature set.

### Commands and Special Keys

Expand Down Expand Up @@ -232,6 +234,24 @@ undefined
undefined
```

### Reverse-i-search
<!-- YAML
added: REPLACEME
-->

The REPL supports bi-directional reverse-i-search similar to [ZSH][]. It is
triggered with `<ctrl> + R` to search backwards and `<ctrl> + S` to search
forwards.

Duplicated history entires will be skipped.

Entries are accepted as soon as any button is pressed that doesn't correspond
with the reverse search. Cancelling is possible by pressing `escape` or
`<ctrl> + C`.

Changing the direction immediately searches for the next entry in the expected
direction from the current position on.

### Custom Evaluation Functions

When a new [`repl.REPLServer`][] is created, a custom evaluation function may be
Expand Down Expand Up @@ -695,6 +715,7 @@ a `net.Server` and `net.Socket` instance, see:
For an example of running a REPL instance over [curl(1)][], see:
<https://gist.github.com/TooTallNate/2053342>.

[ZSH]: https://en.wikipedia.org/wiki/Z_shell
[`'uncaughtException'`]: process.html#process_event_uncaughtexception
[`--experimental-repl-await`]: cli.html#cli_experimental_repl_await
[`ERR_DOMAIN_CANNOT_SET_UNCAUGHT_EXCEPTION_CAPTURE`]: errors.html#errors_err_domain_cannot_set_uncaught_exception_capture
Expand Down
274 changes: 258 additions & 16 deletions lib/internal/repl/utils.js
Expand Up @@ -24,6 +24,7 @@ const {

const {
clearLine,
clearScreenDown,
cursorTo,
moveCursor,
} = require('readline');
Expand All @@ -42,7 +43,13 @@ const inspectOptions = {
compact: true,
breakLength: Infinity
};
const inspectedOptions = inspect(inspectOptions, { colors: false });
// Specify options that might change the output in a way that it's not a valid
// stringified object anymore.
const inspectedOptions = inspect(inspectOptions, {
depth: 1,
colors: false,
showHidden: false
});

// If the error is that we've unexpectedly ended the input,
// then let the user try to recover by adding more input.
Expand Down Expand Up @@ -132,11 +139,19 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
let previewCompletionCounter = 0;
let completionPreview = null;

function getPreviewPos() {
const displayPos = repl._getDisplayPos(`${repl._prompt}${repl.line}`);
const cursorPos = repl._getCursorPos();
const rows = 1 + displayPos.rows - cursorPos.rows;
return { rows, cols: cursorPos.cols };
}

const clearPreview = () => {
if (inputPreview !== null) {
moveCursor(repl.output, 0, 1);
const { rows } = getPreviewPos();
moveCursor(repl.output, 0, rows);
clearLine(repl.output);
moveCursor(repl.output, 0, -1);
moveCursor(repl.output, 0, -rows);
lastInputPreview = inputPreview;
inputPreview = null;
}
Expand Down Expand Up @@ -280,16 +295,6 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
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
Expand Down Expand Up @@ -344,9 +349,12 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
`\u001b[90m${inspected}\u001b[39m` :
`// ${inspected}`;

const { rows: previewRows, cols: cursorCols } = getPreviewPos();
if (previewRows !== 1)
moveCursor(repl.output, 0, previewRows - 1);
const { cols: resultCols } = repl._getDisplayPos(result);
repl.output.write(`\n${result}`);
moveCursor(repl.output, 0, -1);
cursorTo(repl.output, repl._prompt.length + repl.cursor);
moveCursor(repl.output, cursorCols - resultCols, -previewRows);
});
};

Expand Down Expand Up @@ -392,8 +400,242 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
return { showPreview, clearPreview };
}

function setupReverseSearch(repl) {
// Simple terminals can't use reverse search.
if (process.env.TERM === 'dumb') {
BridgeAR marked this conversation as resolved.
Show resolved Hide resolved
return { reverseSearch() { return false; } };
}

const alreadyMatched = new Set();
const labels = {
r: 'bck-i-search: ',
Copy link
Member

Choose a reason for hiding this comment

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

Can these prompts be less abbreviated?

Copy link
Member Author

Choose a reason for hiding this comment

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

Do you have a suggestion? I thought it's nice to keep it aligned with ZSH and to also keep the length of both strings identical. backward-i-search and forward-i-search has a one character length difference.

Copy link
Member Author

Choose a reason for hiding this comment

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

Is it OK to land this as is? We can also change the name later on in a patch.

s: 'fwd-i-search: '
BridgeAR marked this conversation as resolved.
Show resolved Hide resolved
};
let isInReverseSearch = false;
let historyIndex = -1;
let input = '';
let cursor = -1;
let dir = 'r';
let lastMatch = -1;
let lastCursor = -1;
let promptPos;

function checkAndSetDirectionKey(keyName) {
if (!labels[keyName]) {
return false;
}
if (dir !== keyName) {
// Reset the already matched set in case the direction is changed. That
// way it's possible to find those entries again.
alreadyMatched.clear();
}
dir = keyName;
return true;
}

function goToNextHistoryIndex() {
// Ignore this entry for further searches and continue to the next
// history entry.
alreadyMatched.add(repl.history[historyIndex]);
historyIndex += dir === 'r' ? 1 : -1;
cursor = -1;
}

function search() {
// Just print an empty line in case the user removed the search parameter.
if (input === '') {
print(repl.line, `${labels[dir]}_`);
return;
}
// Fix the bounds in case the direction has changed in the meanwhile.
if (dir === 'r') {
if (historyIndex < 0) {
historyIndex = 0;
}
} else if (historyIndex >= repl.history.length) {
historyIndex = repl.history.length - 1;
}
// Check the history entries until a match is found.
while (historyIndex >= 0 && historyIndex < repl.history.length) {
let entry = repl.history[historyIndex];
// Visualize all potential matches only once.
if (alreadyMatched.has(entry)) {
historyIndex += dir === 'r' ? 1 : -1;
continue;
}
// Match the next entry either from the start or from the end, depending
// on the current direction.
if (dir === 'r') {
// Update the cursor in case it's necessary.
if (cursor === -1) {
cursor = entry.length;
}
cursor = entry.lastIndexOf(input, cursor - 1);
} else {
cursor = entry.indexOf(input, cursor + 1);
}
// Match not found.
if (cursor === -1) {
goToNextHistoryIndex();
// Match found.
} else {
if (repl.useColors) {
const start = entry.slice(0, cursor);
const end = entry.slice(cursor + input.length);
entry = `${start}\x1B[4m${input}\x1B[24m${end}`;
}
print(entry, `${labels[dir]}${input}_`, cursor);
lastMatch = historyIndex;
lastCursor = cursor;
// Explicitly go to the next history item in case no further matches are
// possible with the current entry.
if ((dir === 'r' && cursor === 0) ||
(dir === 's' && entry.length === cursor + input.length)) {
goToNextHistoryIndex();
}
return;
}
}
print(repl.line, `failed-${labels[dir]}${input}_`);
}

function print(outputLine, inputLine, cursor = repl.cursor) {
// TODO(BridgeAR): Resizing the terminal window hides the overlay. To fix
// that, readline must be aware of this information. It's probably best to
// add a couple of properties to readline that allow to do the following:
// 1. Add arbitrary data to the end of the current line while not counting
// towards the line. This would be useful for the completion previews.
// 2. Add arbitrary extra lines that do not count towards the regular line.
// This would be useful for both, the input preview and the reverse
// search. It might be combined with the first part?
// 3. Add arbitrary input that is "on top" of the current line. That is
// useful for the reverse search.
// 4. To trigger the line refresh, functions should be used to pass through
// the information. Alternatively, getters and setters could be used.
// That might even be more elegant.
// The data would then be accounted for when calling `_refreshLine()`.
// This function would then look similar to:
// repl.overlay(outputLine);
// repl.addTrailingLine(inputLine);
// repl.setCursor(cursor);
// More potential improvements: use something similar to stream.cork().
// Multiple cursor moves on the same tick could be prevented in case all
// writes from the same tick are combined and the cursor is moved at the
// tick end instead of after each operation.
let rows = 0;
if (lastMatch !== -1) {
const line = repl.history[lastMatch].slice(0, lastCursor);
rows = repl._getDisplayPos(`${repl._prompt}${line}`).rows;
cursorTo(repl.output, promptPos.cols);
} else if (isInReverseSearch && repl.line !== '') {
rows = repl._getCursorPos().rows;
cursorTo(repl.output, promptPos.cols);
}
if (rows !== 0)
moveCursor(repl.output, 0, -rows);

if (isInReverseSearch) {
clearScreenDown(repl.output);
repl.output.write(`${outputLine}\n${inputLine}`);
} else {
repl.output.write(`\n${inputLine}`);
}

lastMatch = -1;

// To know exactly how many rows we have to move the cursor back we need the
// cursor rows, the output rows and the input rows.
const prompt = repl._prompt;
const cursorLine = `${prompt}${outputLine.slice(0, cursor)}`;
const cursorPos = repl._getDisplayPos(cursorLine);
const outputPos = repl._getDisplayPos(`${prompt}${outputLine}`);
const inputPos = repl._getDisplayPos(inputLine);
const inputRows = inputPos.rows - (inputPos.cols === 0 ? 1 : 0);

rows = -1 - inputRows - (outputPos.rows - cursorPos.rows);

moveCursor(repl.output, 0, rows);
cursorTo(repl.output, cursorPos.cols);
}

function reset(string) {
isInReverseSearch = string !== undefined;

// In case the reverse search ends and a history entry is found, reset the
// line to the found entry.
if (!isInReverseSearch) {
if (lastMatch !== -1) {
repl.line = repl.history[lastMatch];
repl.cursor = lastCursor;
repl.historyIndex = lastMatch;
}

lastMatch = -1;

// Clear screen and write the current repl.line before exiting.
cursorTo(repl.output, promptPos.cols);
if (promptPos.rows !== 0)
moveCursor(repl.output, 0, promptPos.rows);
clearScreenDown(repl.output);
if (repl.line !== '') {
repl.output.write(repl.line);
if (repl.line.length !== repl.cursor) {
const { cols, rows } = repl._getCursorPos();
cursorTo(repl.output, cols);
if (rows !== 0)
moveCursor(repl.output, 0, rows);
}
}
}

input = string || '';
cursor = -1;
historyIndex = repl.historyIndex;
alreadyMatched.clear();
}

function reverseSearch(string, key) {
if (!isInReverseSearch) {
if (key.ctrl && checkAndSetDirectionKey(key.name)) {
historyIndex = repl.historyIndex;
promptPos = repl._getDisplayPos(`${repl._prompt}`);
print(repl.line, `${labels[dir]}_`);
isInReverseSearch = true;
}
} else if (key.ctrl && checkAndSetDirectionKey(key.name)) {
search();
} else if (key.name === 'backspace' ||
(key.ctrl && (key.name === 'h' || key.name === 'w'))) {
reset(input.slice(0, input.length - 1));
search();
// Special handle <ctrl> + c and escape. Those should only cancel the
// reverse search. The original line is visible afterwards again.
} else if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
lastMatch = -1;
reset();
return true;
// End search in case either enter is pressed or if any non-reverse-search
// key (combination) is pressed.
} else if (key.ctrl ||
key.meta ||
key.name === 'return' ||
key.name === 'enter' ||
typeof string !== 'string' ||
string === '') {
reset();
} else {
reset(`${input}${string}`);
search();
}
return isInReverseSearch;
}

return { reverseSearch };
}

module.exports = {
isRecoverableError,
kStandaloneREPL: Symbol('kStandaloneREPL'),
setupPreview
setupPreview,
setupReverseSearch
};