Skip to content

Commit 75d46ab

Browse files
committed
lib: support reverse search in repl
1 parent 2e613a9 commit 75d46ab

File tree

2 files changed

+253
-4
lines changed

2 files changed

+253
-4
lines changed

lib/readline.js

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ const {
5252
kClearScreenDown
5353
} = CSI;
5454

55+
// stuff used for reverse search
56+
const kReverseSearchPrompt = "(reverse-i-search)`':";
57+
const kFailedReverseSearchPrompt = '(failed-reverse-i-search)`';
58+
5559
// Lazy load StringDecoder for startup performance.
5660
let StringDecoder;
5761

@@ -75,6 +79,13 @@ function createInterface(input, output, completer, terminal) {
7579
return new Interface(input, output, completer, terminal);
7680
}
7781

82+
function buildReverseSearchPrompt(text, match) {
83+
if (text === undefined)
84+
return kReverseSearchPrompt;
85+
if (match === '')
86+
return `${kFailedReverseSearchPrompt}${text}':`;
87+
return `(reverse-i-search)\`${text}': ${match}`;
88+
}
7889

7990
function Interface(input, output, completer, terminal) {
8091
if (!(this instanceof Interface)) {
@@ -90,6 +101,9 @@ function Interface(input, output, completer, terminal) {
90101
this._previousKey = null;
91102
this.escapeCodeTimeout = ESCAPE_CODE_TIMEOUT;
92103

104+
this.inReverseSearch = false;
105+
this.reverseSearchIndex = 0;
106+
93107
EventEmitter.call(this);
94108
var historySize;
95109
var removeHistoryDuplicates = false;
@@ -347,7 +361,8 @@ Interface.prototype._addHistory = function() {
347361

348362
Interface.prototype._refreshLine = function() {
349363
// line length
350-
var line = this._prompt + this.line;
364+
const line = this._prompt + this.line;
365+
351366
var dispPos = this._getDisplayPos(line);
352367
var lineCols = dispPos.cols;
353368
var lineRows = dispPos.rows;
@@ -383,6 +398,8 @@ Interface.prototype._refreshLine = function() {
383398
}
384399

385400
this.prevRows = cursorPos.rows;
401+
402+
searchHistory.call(this);
386403
};
387404

388405

@@ -474,8 +491,60 @@ Interface.prototype._insertString = function(c) {
474491
// a hack to get the line refreshed if it's needed
475492
this._moveCursor(0);
476493
}
494+
495+
searchHistory.call(this);
477496
};
478497

498+
function appendSearchResult(result) {
499+
// this.previewResult = result;
500+
501+
// Cursor to left edge.
502+
cursorTo(this.output, 0);
503+
clearScreenDown(this.output);
504+
505+
// Based on the line and match result
506+
// write the data
507+
if (result !== '') {
508+
this.output.write(buildReverseSearchPrompt(this.line, result));
509+
cursorTo(this.output, this.cursor + kReverseSearchPrompt.length - 2);
510+
} else if (this.line === '') {
511+
this.output.write(buildReverseSearchPrompt());
512+
cursorTo(this.output, this.cursor + kReverseSearchPrompt.length - 2);
513+
} else {
514+
this.output.write(buildReverseSearchPrompt(this.line, ''));
515+
cursorTo(this.output, this.cursor + kFailedReverseSearchPrompt.length);
516+
}
517+
518+
}
519+
520+
function searchText() {
521+
let result = '';
522+
const historySet = new Set(this.history);
523+
for (;this.reverseSearchIndex < [...historySet].length;
524+
this.reverseSearchIndex++) {
525+
if (this.line.trim() !== '' &&
526+
this.history[this.reverseSearchIndex].includes(this.line)) {
527+
result = this.history[this.reverseSearchIndex++];
528+
break;
529+
}
530+
}
531+
532+
return result;
533+
}
534+
535+
function searchHistory() {
536+
if (this.inReverseSearch) {
537+
let result = searchText.call(this);
538+
const historySet = new Set(this.history);
539+
if (this.reverseSearchIndex >= [...historySet].length) {
540+
this.reverseSearchIndex = 0;
541+
542+
result = searchText.call(this);
543+
}
544+
appendSearchResult.call(this, result);
545+
}
546+
}
547+
479548
Interface.prototype._tabComplete = function(lastKeypressWasTab) {
480549
var self = this;
481550

@@ -768,16 +837,25 @@ Interface.prototype._moveCursor = function(dx) {
768837
}
769838
};
770839

840+
function breakOutOfReverseSearch() {
841+
this.inReverseSearch = false;
842+
this._refreshLine();
843+
}
771844

772845
// handle a write from the tty
773846
Interface.prototype._ttyWrite = function(s, key) {
774847
const previousKey = this._previousKey;
775848
key = key || {};
776849
this._previousKey = key;
777850

778-
// Ignore escape key, fixes
779-
// https://github.com/nodejs/node-v0.x-archive/issues/2876.
780-
if (key.name === 'escape') return;
851+
if (key.name === 'escape') {
852+
if (this.inReverseSearch) {
853+
breakOutOfReverseSearch.call(this);
854+
}
855+
// Else, ignore escape key. Fixes:
856+
// https://github.com/nodejs/node-v0.x-archive/issues/2876.
857+
return;
858+
}
781859

782860
if (key.ctrl && key.shift) {
783861
/* Control and shift pressed */
@@ -802,6 +880,11 @@ Interface.prototype._ttyWrite = function(s, key) {
802880
// This readline instance is finished
803881
this.close();
804882
}
883+
884+
if (this.inReverseSearch) {
885+
breakOutOfReverseSearch.call(this);
886+
}
887+
805888
break;
806889

807890
case 'h': // delete left
@@ -897,6 +980,11 @@ Interface.prototype._ttyWrite = function(s, key) {
897980
case 'right':
898981
this._wordRight();
899982
break;
983+
984+
case 'r':
985+
if (!this.inReverseSearch)
986+
this.inReverseSearch = true;
987+
searchHistory.call(this);
900988
}
901989

902990
} else if (key.meta) {
@@ -931,6 +1019,11 @@ Interface.prototype._ttyWrite = function(s, key) {
9311019
switch (key.name) {
9321020
case 'return': // carriage return, i.e. \r
9331021
this._sawReturnAt = Date.now();
1022+
if (this.inReverseSearch) {
1023+
this.line = this.history[this.reverseSearchIndex - 1];
1024+
this.inReverseSearch = false;
1025+
this.reverseSearchIndex = 0;
1026+
}
9341027
this._line();
9351028
break;
9361029

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
'use strict';
2+
3+
// Flags: --expose-internals
4+
5+
const common = require('../common');
6+
const stream = require('stream');
7+
const REPL = require('internal/repl');
8+
const assert = require('assert');
9+
const fs = require('fs');
10+
const path = require('path');
11+
12+
const tmpdir = require('../common/tmpdir');
13+
tmpdir.refresh();
14+
15+
const defaultHistoryPath = path.join(tmpdir.path, '.node_repl_history');
16+
17+
// Create an input stream specialized for testing an array of actions
18+
class ActionStream extends stream.Stream {
19+
run(data) {
20+
const _iter = data[Symbol.iterator]();
21+
const doAction = () => {
22+
const next = _iter.next();
23+
if (next.done) {
24+
// Close the repl. Note that it must have a clean prompt to do so.
25+
this.emit('keypress', '', { ctrl: true, name: 'd' });
26+
return;
27+
}
28+
const action = next.value;
29+
30+
if (typeof action === 'object') {
31+
this.emit('keypress', '', action);
32+
} else {
33+
this.emit('data', `${action}`);
34+
}
35+
setImmediate(doAction);
36+
};
37+
setImmediate(doAction);
38+
}
39+
resume() {}
40+
pause() {}
41+
}
42+
ActionStream.prototype.readable = true;
43+
44+
45+
// Mock keys
46+
const ENTER = { name: 'enter' };
47+
const CLEAR = { ctrl: true, name: 'u' };
48+
const ESCAPE = { name: 'escape' };
49+
const SEARCH = { ctrl: true, name: 'r' };
50+
51+
const prompt = '> ';
52+
const reverseSearchPrompt = '(reverse-i-search)`\':';
53+
54+
55+
const wrapWithSearchTexts = (code, result) => {
56+
return `(reverse-i-search)\`${code}': ${result}`;
57+
};
58+
const tests = [
59+
{ // creates few history to search for
60+
env: { NODE_REPL_HISTORY: defaultHistoryPath },
61+
test: ['\' search\'.trim()', ENTER, 'let ab = 45', ENTER,
62+
'555 + 909', ENTER, '{key : {key2 :[] }}', ENTER],
63+
expected: [],
64+
clean: false
65+
},
66+
{
67+
env: { NODE_REPL_HISTORY: defaultHistoryPath },
68+
test: [SEARCH, 's', ESCAPE, CLEAR],
69+
expected: [reverseSearchPrompt,
70+
wrapWithSearchTexts('s', '\' search\'.trim()')]
71+
},
72+
{
73+
env: { NODE_REPL_HISTORY: defaultHistoryPath },
74+
test: ['s', SEARCH, ESCAPE, CLEAR],
75+
expected: [wrapWithSearchTexts('s', '\' search\'.trim()')]
76+
},
77+
{
78+
env: { NODE_REPL_HISTORY: defaultHistoryPath },
79+
test: ['5', SEARCH, SEARCH, ESCAPE, CLEAR],
80+
expected: [wrapWithSearchTexts('5', '555 + 909'),
81+
wrapWithSearchTexts('5', 'let ab = 45')]
82+
},
83+
{
84+
env: { NODE_REPL_HISTORY: defaultHistoryPath },
85+
test: ['*', SEARCH, ESCAPE, CLEAR],
86+
expected: ['(failed-reverse-i-search)`*\':'],
87+
clean: true
88+
}
89+
];
90+
91+
function cleanupTmpFile() {
92+
try {
93+
// Write over the file, clearing any history
94+
fs.writeFileSync(defaultHistoryPath, '');
95+
} catch (err) {
96+
if (err.code === 'ENOENT') return true;
97+
throw err;
98+
}
99+
return true;
100+
}
101+
102+
const numtests = tests.length;
103+
104+
const runTestWrap = common.mustCall(runTest, numtests);
105+
106+
function runTest() {
107+
const opts = tests.shift();
108+
if (!opts) return; // All done
109+
110+
const env = opts.env;
111+
const test = opts.test;
112+
const expected = opts.expected;
113+
114+
REPL.createInternalRepl(env, {
115+
input: new ActionStream(),
116+
output: new stream.Writable({
117+
write(chunk, _, next) {
118+
const output = chunk.toString();
119+
120+
if (!output.includes('reverse-i-search')) {
121+
return next();
122+
}
123+
124+
if (expected.length) {
125+
assert.strictEqual(output, expected[0]);
126+
expected.shift();
127+
}
128+
129+
next();
130+
}
131+
}),
132+
prompt: prompt,
133+
useColors: false,
134+
terminal: true
135+
}, function(err, repl) {
136+
if (err) {
137+
console.error(`Failed test # ${numtests - tests.length}`);
138+
throw err;
139+
}
140+
141+
repl.once('close', () => {
142+
if (opts.clean)
143+
cleanupTmpFile();
144+
145+
if (expected.length !== 0) {
146+
throw new Error(`Failed test # ${numtests - tests.length}`);
147+
}
148+
setImmediate(runTestWrap, true);
149+
});
150+
151+
repl.inputStream.run(test);
152+
});
153+
}
154+
155+
// run the tests
156+
runTest();

0 commit comments

Comments
 (0)