Skip to content

Commit 0520b3d

Browse files
BridgeARtargos
authored andcommitted
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: nodejs#31006 Reviewed-By: Benjamin Gruenbaum <[email protected]> Reviewed-By: Anto Aravinth <[email protected]> Reviewed-By: Rich Trott <[email protected]>
1 parent 8208aa7 commit 0520b3d

File tree

7 files changed

+653
-13
lines changed

7 files changed

+653
-13
lines changed

doc/api/repl.md

Lines changed: 24 additions & 3 deletions
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
@@ -705,6 +725,7 @@ a `net.Server` and `net.Socket` instance, see:
705725
For an example of running a REPL instance over [curl(1)][], see:
706726
<https://gist.github.com/TooTallNate/2053342>.
707727

728+
[ZSH]: https://en.wikipedia.org/wiki/Z_shell
708729
[`'uncaughtException'`]: process.html#process_event_uncaughtexception
709730
[`--experimental-repl-await`]: cli.html#cli_experimental_repl_await
710731
[`ERR_DOMAIN_CANNOT_SET_UNCAUGHT_EXCEPTION_CAPTURE`]: errors.html#errors_err_domain_cannot_set_uncaught_exception_capture

lib/internal/repl/utils.js

Lines changed: 244 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const {
44
MathMin,
5+
Set,
56
Symbol,
67
} = primordials;
78

@@ -24,6 +25,7 @@ const {
2425

2526
const {
2627
clearLine,
28+
clearScreenDown,
2729
cursorTo,
2830
moveCursor,
2931
} = require('readline');
@@ -42,7 +44,13 @@ const inspectOptions = {
4244
compact: true,
4345
breakLength: Infinity
4446
};
45-
const inspectedOptions = inspect(inspectOptions, { colors: false });
47+
// Specify options that might change the output in a way that it's not a valid
48+
// stringified object anymore.
49+
const inspectedOptions = inspect(inspectOptions, {
50+
depth: 1,
51+
colors: false,
52+
showHidden: false
53+
});
4654

4755
// If the error is that we've unexpectedly ended the input,
4856
// then let the user try to recover by adding more input.
@@ -393,8 +401,242 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
393401
return { showPreview, clearPreview };
394402
}
395403

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

lib/repl.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ const {
107107
isRecoverableError,
108108
kStandaloneREPL,
109109
setupPreview,
110+
setupReverseSearch,
110111
} = require('internal/repl/utils');
111112
const {
112113
getOwnNonIndexProperties,
@@ -815,6 +816,8 @@ function REPLServer(prompt,
815816
}
816817
});
817818

819+
const { reverseSearch } = setupReverseSearch(this);
820+
818821
const {
819822
clearPreview,
820823
showPreview
@@ -840,8 +843,10 @@ function REPLServer(prompt,
840843
self.clearLine();
841844
}
842845
clearPreview();
843-
ttyWrite(d, key);
844-
showPreview();
846+
if (!reverseSearch(d, key)) {
847+
ttyWrite(d, key);
848+
showPreview();
849+
}
845850
return;
846851
}
847852

@@ -1086,6 +1091,9 @@ REPLServer.prototype.complete = function() {
10861091
this.completer.apply(this, arguments);
10871092
};
10881093

1094+
// TODO: Native module names should be auto-resolved.
1095+
// That improves the auto completion.
1096+
10891097
// Provide a list of completions for the given leading text. This is
10901098
// given to the readline interface for handling tab completion.
10911099
//

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

Lines changed: 1 addition & 1 deletion
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)