Skip to content

Commit 09ca8be

Browse files
committed
repl: implement reverse search
Add a reverse search that works similar to the ZSH one. It is triggered with <ctrl> + r and <ctrl> + s. It skips duplicated history entries and works with multiline statements. Matching entries indicate the search parameter with an underscore and cancelling with <ctrl> + c or escape brings back the original line. Multiple matches in a single history entry work as well and are matched in the order of the current search direction. The cursor is positioned at the current match position of the history entry. Changing the direction immediately checks for the next entry in the expected direction from the current position on. Entries are accepted as soon any button is pressed that doesn't correspond with the reverse search. The behavior is deactivated for simple terminals. They do not support most ANSI escape codes that are necessary for this feature. PR-URL: #31006 Reviewed-By: Benjamin Gruenbaum <[email protected]> Reviewed-By: Anto Aravinth <[email protected]> Reviewed-By: Rich Trott <[email protected]>
1 parent 925dd8e commit 09ca8be

File tree

7 files changed

+652
-13
lines changed

7 files changed

+652
-13
lines changed

doc/api/repl.md

+24-3
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ result. Input and output may be from `stdin` and `stdout`, respectively, or may
2121
be connected to any Node.js [stream][].
2222

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

2830
### Commands and Special Keys
2931

@@ -232,6 +234,24 @@ undefined
232234
undefined
233235
```
234236

237+
### Reverse-i-search
238+
<!-- YAML
239+
added: REPLACEME
240+
-->
241+
242+
The REPL supports bi-directional reverse-i-search similar to [ZSH][]. It is
243+
triggered with `<ctrl> + R` to search backwards and `<ctrl> + S` to search
244+
forwards.
245+
246+
Duplicated history entires will be skipped.
247+
248+
Entries are accepted as soon as any button is pressed that doesn't correspond
249+
with the reverse search. Cancelling is possible by pressing `escape` or
250+
`<ctrl> + C`.
251+
252+
Changing the direction immediately searches for the next entry in the expected
253+
direction from the current position on.
254+
235255
### Custom Evaluation Functions
236256

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

718+
[ZSH]: https://en.wikipedia.org/wiki/Z_shell
698719
[`'uncaughtException'`]: process.html#process_event_uncaughtexception
699720
[`--experimental-repl-await`]: cli.html#cli_experimental_repl_await
700721
[`ERR_DOMAIN_CANNOT_SET_UNCAUGHT_EXCEPTION_CAPTURE`]: errors.html#errors_err_domain_cannot_set_uncaught_exception_capture

lib/internal/repl/utils.js

+243-2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const {
2424

2525
const {
2626
clearLine,
27+
clearScreenDown,
2728
cursorTo,
2829
moveCursor,
2930
} = require('readline');
@@ -42,7 +43,13 @@ const inspectOptions = {
4243
compact: true,
4344
breakLength: Infinity
4445
};
45-
const inspectedOptions = inspect(inspectOptions, { colors: false });
46+
// Specify options that might change the output in a way that it's not a valid
47+
// stringified object anymore.
48+
const inspectedOptions = inspect(inspectOptions, {
49+
depth: 1,
50+
colors: false,
51+
showHidden: false
52+
});
4653

4754
// If the error is that we've unexpectedly ended the input,
4855
// then let the user try to recover by adding more input.
@@ -393,8 +400,242 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
393400
return { showPreview, clearPreview };
394401
}
395402

403+
function setupReverseSearch(repl) {
404+
// Simple terminals can't use reverse search.
405+
if (process.env.TERM === 'dumb') {
406+
return { reverseSearch() { return false; } };
407+
}
408+
409+
const alreadyMatched = new Set();
410+
const labels = {
411+
r: 'bck-i-search: ',
412+
s: 'fwd-i-search: '
413+
};
414+
let isInReverseSearch = false;
415+
let historyIndex = -1;
416+
let input = '';
417+
let cursor = -1;
418+
let dir = 'r';
419+
let lastMatch = -1;
420+
let lastCursor = -1;
421+
let promptPos;
422+
423+
function checkAndSetDirectionKey(keyName) {
424+
if (!labels[keyName]) {
425+
return false;
426+
}
427+
if (dir !== keyName) {
428+
// Reset the already matched set in case the direction is changed. That
429+
// way it's possible to find those entries again.
430+
alreadyMatched.clear();
431+
}
432+
dir = keyName;
433+
return true;
434+
}
435+
436+
function goToNextHistoryIndex() {
437+
// Ignore this entry for further searches and continue to the next
438+
// history entry.
439+
alreadyMatched.add(repl.history[historyIndex]);
440+
historyIndex += dir === 'r' ? 1 : -1;
441+
cursor = -1;
442+
}
443+
444+
function search() {
445+
// Just print an empty line in case the user removed the search parameter.
446+
if (input === '') {
447+
print(repl.line, `${labels[dir]}_`);
448+
return;
449+
}
450+
// Fix the bounds in case the direction has changed in the meanwhile.
451+
if (dir === 'r') {
452+
if (historyIndex < 0) {
453+
historyIndex = 0;
454+
}
455+
} else if (historyIndex >= repl.history.length) {
456+
historyIndex = repl.history.length - 1;
457+
}
458+
// Check the history entries until a match is found.
459+
while (historyIndex >= 0 && historyIndex < repl.history.length) {
460+
let entry = repl.history[historyIndex];
461+
// Visualize all potential matches only once.
462+
if (alreadyMatched.has(entry)) {
463+
historyIndex += dir === 'r' ? 1 : -1;
464+
continue;
465+
}
466+
// Match the next entry either from the start or from the end, depending
467+
// on the current direction.
468+
if (dir === 'r') {
469+
// Update the cursor in case it's necessary.
470+
if (cursor === -1) {
471+
cursor = entry.length;
472+
}
473+
cursor = entry.lastIndexOf(input, cursor - 1);
474+
} else {
475+
cursor = entry.indexOf(input, cursor + 1);
476+
}
477+
// Match not found.
478+
if (cursor === -1) {
479+
goToNextHistoryIndex();
480+
// Match found.
481+
} else {
482+
if (repl.useColors) {
483+
const start = entry.slice(0, cursor);
484+
const end = entry.slice(cursor + input.length);
485+
entry = `${start}\x1B[4m${input}\x1B[24m${end}`;
486+
}
487+
print(entry, `${labels[dir]}${input}_`, cursor);
488+
lastMatch = historyIndex;
489+
lastCursor = cursor;
490+
// Explicitly go to the next history item in case no further matches are
491+
// possible with the current entry.
492+
if ((dir === 'r' && cursor === 0) ||
493+
(dir === 's' && entry.length === cursor + input.length)) {
494+
goToNextHistoryIndex();
495+
}
496+
return;
497+
}
498+
}
499+
print(repl.line, `failed-${labels[dir]}${input}_`);
500+
}
501+
502+
function print(outputLine, inputLine, cursor = repl.cursor) {
503+
// TODO(BridgeAR): Resizing the terminal window hides the overlay. To fix
504+
// that, readline must be aware of this information. It's probably best to
505+
// add a couple of properties to readline that allow to do the following:
506+
// 1. Add arbitrary data to the end of the current line while not counting
507+
// towards the line. This would be useful for the completion previews.
508+
// 2. Add arbitrary extra lines that do not count towards the regular line.
509+
// This would be useful for both, the input preview and the reverse
510+
// search. It might be combined with the first part?
511+
// 3. Add arbitrary input that is "on top" of the current line. That is
512+
// useful for the reverse search.
513+
// 4. To trigger the line refresh, functions should be used to pass through
514+
// the information. Alternatively, getters and setters could be used.
515+
// That might even be more elegant.
516+
// The data would then be accounted for when calling `_refreshLine()`.
517+
// This function would then look similar to:
518+
// repl.overlay(outputLine);
519+
// repl.addTrailingLine(inputLine);
520+
// repl.setCursor(cursor);
521+
// More potential improvements: use something similar to stream.cork().
522+
// Multiple cursor moves on the same tick could be prevented in case all
523+
// writes from the same tick are combined and the cursor is moved at the
524+
// tick end instead of after each operation.
525+
let rows = 0;
526+
if (lastMatch !== -1) {
527+
const line = repl.history[lastMatch].slice(0, lastCursor);
528+
rows = repl._getDisplayPos(`${repl._prompt}${line}`).rows;
529+
cursorTo(repl.output, promptPos.cols);
530+
} else if (isInReverseSearch && repl.line !== '') {
531+
rows = repl._getCursorPos().rows;
532+
cursorTo(repl.output, promptPos.cols);
533+
}
534+
if (rows !== 0)
535+
moveCursor(repl.output, 0, -rows);
536+
537+
if (isInReverseSearch) {
538+
clearScreenDown(repl.output);
539+
repl.output.write(`${outputLine}\n${inputLine}`);
540+
} else {
541+
repl.output.write(`\n${inputLine}`);
542+
}
543+
544+
lastMatch = -1;
545+
546+
// To know exactly how many rows we have to move the cursor back we need the
547+
// cursor rows, the output rows and the input rows.
548+
const prompt = repl._prompt;
549+
const cursorLine = `${prompt}${outputLine.slice(0, cursor)}`;
550+
const cursorPos = repl._getDisplayPos(cursorLine);
551+
const outputPos = repl._getDisplayPos(`${prompt}${outputLine}`);
552+
const inputPos = repl._getDisplayPos(inputLine);
553+
const inputRows = inputPos.rows - (inputPos.cols === 0 ? 1 : 0);
554+
555+
rows = -1 - inputRows - (outputPos.rows - cursorPos.rows);
556+
557+
moveCursor(repl.output, 0, rows);
558+
cursorTo(repl.output, cursorPos.cols);
559+
}
560+
561+
function reset(string) {
562+
isInReverseSearch = string !== undefined;
563+
564+
// In case the reverse search ends and a history entry is found, reset the
565+
// line to the found entry.
566+
if (!isInReverseSearch) {
567+
if (lastMatch !== -1) {
568+
repl.line = repl.history[lastMatch];
569+
repl.cursor = lastCursor;
570+
repl.historyIndex = lastMatch;
571+
}
572+
573+
lastMatch = -1;
574+
575+
// Clear screen and write the current repl.line before exiting.
576+
cursorTo(repl.output, promptPos.cols);
577+
if (promptPos.rows !== 0)
578+
moveCursor(repl.output, 0, promptPos.rows);
579+
clearScreenDown(repl.output);
580+
if (repl.line !== '') {
581+
repl.output.write(repl.line);
582+
if (repl.line.length !== repl.cursor) {
583+
const { cols, rows } = repl._getCursorPos();
584+
cursorTo(repl.output, cols);
585+
if (rows !== 0)
586+
moveCursor(repl.output, 0, rows);
587+
}
588+
}
589+
}
590+
591+
input = string || '';
592+
cursor = -1;
593+
historyIndex = repl.historyIndex;
594+
alreadyMatched.clear();
595+
}
596+
597+
function reverseSearch(string, key) {
598+
if (!isInReverseSearch) {
599+
if (key.ctrl && checkAndSetDirectionKey(key.name)) {
600+
historyIndex = repl.historyIndex;
601+
promptPos = repl._getDisplayPos(`${repl._prompt}`);
602+
print(repl.line, `${labels[dir]}_`);
603+
isInReverseSearch = true;
604+
}
605+
} else if (key.ctrl && checkAndSetDirectionKey(key.name)) {
606+
search();
607+
} else if (key.name === 'backspace' ||
608+
(key.ctrl && (key.name === 'h' || key.name === 'w'))) {
609+
reset(input.slice(0, input.length - 1));
610+
search();
611+
// Special handle <ctrl> + c and escape. Those should only cancel the
612+
// reverse search. The original line is visible afterwards again.
613+
} else if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
614+
lastMatch = -1;
615+
reset();
616+
return true;
617+
// End search in case either enter is pressed or if any non-reverse-search
618+
// key (combination) is pressed.
619+
} else if (key.ctrl ||
620+
key.meta ||
621+
key.name === 'return' ||
622+
key.name === 'enter' ||
623+
typeof string !== 'string' ||
624+
string === '') {
625+
reset();
626+
} else {
627+
reset(`${input}${string}`);
628+
search();
629+
}
630+
return isInReverseSearch;
631+
}
632+
633+
return { reverseSearch };
634+
}
635+
396636
module.exports = {
397637
isRecoverableError,
398638
kStandaloneREPL: Symbol('kStandaloneREPL'),
399-
setupPreview
639+
setupPreview,
640+
setupReverseSearch
400641
};

lib/repl.js

+10-2
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ const {
102102
isRecoverableError,
103103
kStandaloneREPL,
104104
setupPreview,
105+
setupReverseSearch,
105106
} = require('internal/repl/utils');
106107
const {
107108
getOwnNonIndexProperties,
@@ -810,6 +811,8 @@ function REPLServer(prompt,
810811
}
811812
});
812813

814+
const { reverseSearch } = setupReverseSearch(this);
815+
813816
const {
814817
clearPreview,
815818
showPreview
@@ -835,8 +838,10 @@ function REPLServer(prompt,
835838
self.clearLine();
836839
}
837840
clearPreview();
838-
ttyWrite(d, key);
839-
showPreview();
841+
if (!reverseSearch(d, key)) {
842+
ttyWrite(d, key);
843+
showPreview();
844+
}
840845
return;
841846
}
842847

@@ -1081,6 +1086,9 @@ REPLServer.prototype.complete = function() {
10811086
this.completer.apply(this, arguments);
10821087
};
10831088

1089+
// TODO: Native module names should be auto-resolved.
1090+
// That improves the auto completion.
1091+
10841092
// Provide a list of completions for the given leading text. This is
10851093
// given to the readline interface for handling tab completion.
10861094
//

test/parallel/test-repl-history-navigation.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ function runTest() {
340340
const output = chunk.toString();
341341

342342
if (!opts.showEscapeCodes &&
343-
output.charCodeAt(0) === 27 || /^[\r\n]+$/.test(output)) {
343+
(output[0] === '\x1B' || /^[\r\n]+$/.test(output))) {
344344
return next();
345345
}
346346

0 commit comments

Comments
 (0)