Skip to content

Commit b6f4e01

Browse files
BridgeARMylesBorins
authored andcommitted
readline,repl: add substring based history search
This improves the current history search feature by adding substring based history search similar to ZSH. In case the `UP` or `DOWN` buttons are pressed after writing a few characters, the start string up to the current cursor is used to search the history. All other history features work exactly as they used to. PR-URL: #31112 Fixes: #28437 Reviewed-By: Michaël Zasso <targos@protonmail.com> Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent d84c394 commit b6f4e01

File tree

7 files changed

+163
-33
lines changed

7 files changed

+163
-33
lines changed

doc/api/repl.md

+5-4
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ be connected to any Node.js [stream][].
2222

2323
Instances of [`repl.REPLServer`][] support automatic completion of inputs,
2424
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.
25+
[ZSH][]-like reverse-i-search, [ZSH][]-like substring-based history search,
26+
ANSI-styled output, saving and restoring current REPL session state, error
27+
recovery, and customizable evaluation functions. Terminals that do not support
28+
ANSI styles and Emacs-style line editing automatically fall back to a limited
29+
feature set.
2930

3031
### Commands and Special Keys
3132

lib/internal/readline/utils.js

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const {
44
Boolean,
55
NumberIsInteger,
66
RegExp,
7+
Symbol,
78
} = primordials;
89

910
// Regex used for ansi escape code splitting
@@ -17,6 +18,7 @@ const ansi = new RegExp(ansiPattern, 'g');
1718

1819
const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16
1920
const kEscape = '\x1b';
21+
const kSubstringSearch = Symbol('kSubstringSearch');
2022

2123
let getStringWidth;
2224
let isFullWidthCodePoint;
@@ -470,6 +472,7 @@ module.exports = {
470472
emitKeys,
471473
getStringWidth,
472474
isFullWidthCodePoint,
475+
kSubstringSearch,
473476
kUTF16SurrogateThreshold,
474477
stripVTControlCharacters,
475478
CSI

lib/internal/repl/utils.js

+2
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const {
3333
const {
3434
commonPrefix,
3535
getStringWidth,
36+
kSubstringSearch,
3637
} = require('internal/readline/utils');
3738

3839
const { inspect } = require('util');
@@ -646,6 +647,7 @@ function setupReverseSearch(repl) {
646647
typeof string !== 'string' ||
647648
string === '') {
648649
reset();
650+
repl[kSubstringSearch] = '';
649651
} else {
650652
reset(`${input}${string}`);
651653
search();

lib/readline.js

+49-16
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const {
5454
emitKeys,
5555
getStringWidth,
5656
isFullWidthCodePoint,
57+
kSubstringSearch,
5758
kUTF16SurrogateThreshold,
5859
stripVTControlCharacters
5960
} = require('internal/readline/utils');
@@ -153,6 +154,7 @@ function Interface(input, output, completer, terminal) {
153154

154155
const self = this;
155156

157+
this[kSubstringSearch] = null;
156158
this.output = output;
157159
this.input = input;
158160
this.historySize = historySize;
@@ -688,34 +690,51 @@ Interface.prototype._line = function() {
688690
this._onLine(line);
689691
};
690692

691-
693+
// TODO(BridgeAR): Add underscores to the search part and a red background in
694+
// case no match is found. This should only be the visual part and not the
695+
// actual line content!
696+
// TODO(BridgeAR): In case the substring based search is active and the end is
697+
// reached, show a comment how to search the history as before. E.g., using
698+
// <ctrl> + N. Only show this after two/three UPs or DOWNs, not on the first
699+
// one.
692700
Interface.prototype._historyNext = function() {
693-
if (this.historyIndex > 0) {
694-
this.historyIndex--;
695-
this.line = this.history[this.historyIndex];
701+
if (this.historyIndex >= 0) {
702+
const search = this[kSubstringSearch] || '';
703+
let index = this.historyIndex - 1;
704+
while (index >= 0 &&
705+
!this.history[index].startsWith(search)) {
706+
index--;
707+
}
708+
if (index === -1) {
709+
this.line = search;
710+
} else {
711+
this.line = this.history[index];
712+
}
713+
this.historyIndex = index;
696714
this.cursor = this.line.length; // Set cursor to end of line.
697715
this._refreshLine();
698-
699-
} else if (this.historyIndex === 0) {
700-
this.historyIndex = -1;
701-
this.cursor = 0;
702-
this.line = '';
703-
this._refreshLine();
704716
}
705717
};
706718

707-
708719
Interface.prototype._historyPrev = function() {
709-
if (this.historyIndex + 1 < this.history.length) {
710-
this.historyIndex++;
711-
this.line = this.history[this.historyIndex];
720+
if (this.historyIndex < this.history.length) {
721+
const search = this[kSubstringSearch] || '';
722+
let index = this.historyIndex + 1;
723+
while (index < this.history.length &&
724+
!this.history[index].startsWith(search)) {
725+
index++;
726+
}
727+
if (index === this.history.length) {
728+
return;
729+
} else {
730+
this.line = this.history[index];
731+
}
732+
this.historyIndex = index;
712733
this.cursor = this.line.length; // Set cursor to end of line.
713-
714734
this._refreshLine();
715735
}
716736
};
717737

718-
719738
// Returns the last character's display position of the given string
720739
Interface.prototype._getDisplayPos = function(str) {
721740
let offset = 0;
@@ -856,6 +875,20 @@ Interface.prototype._ttyWrite = function(s, key) {
856875
key = key || {};
857876
this._previousKey = key;
858877

878+
// Activate or deactivate substring search.
879+
if ((key.name === 'up' || key.name === 'down') &&
880+
!key.ctrl && !key.meta && !key.shift) {
881+
if (this[kSubstringSearch] === null) {
882+
this[kSubstringSearch] = this.line.slice(0, this.cursor);
883+
}
884+
} else if (this[kSubstringSearch] !== null) {
885+
this[kSubstringSearch] = null;
886+
// Reset the index in case there's no match.
887+
if (this.history.length === this.historyIndex) {
888+
this.historyIndex = -1;
889+
}
890+
}
891+
859892
// Ignore escape key, fixes
860893
// https://github.com/nodejs/node-v0.x-archive/issues/2876.
861894
if (key.name === 'escape') return;

test/parallel/test-readline-interface.js

+34-2
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ function isWarned(emitter) {
430430
removeHistoryDuplicates: true
431431
});
432432
const expectedLines = ['foo', 'bar', 'baz', 'bar', 'bat', 'bat'];
433+
// ['foo', 'baz', 'bar', bat'];
433434
let callCount = 0;
434435
rli.on('line', function(line) {
435436
assert.strictEqual(line, expectedLines[callCount]);
@@ -450,12 +451,43 @@ function isWarned(emitter) {
450451
assert.strictEqual(callCount, 0);
451452
fi.emit('keypress', '.', { name: 'down' }); // 'baz'
452453
assert.strictEqual(rli.line, 'baz');
454+
assert.strictEqual(rli.historyIndex, 2);
453455
fi.emit('keypress', '.', { name: 'n', ctrl: true }); // 'bar'
454456
assert.strictEqual(rli.line, 'bar');
457+
assert.strictEqual(rli.historyIndex, 1);
458+
fi.emit('keypress', '.', { name: 'n', ctrl: true });
459+
assert.strictEqual(rli.line, 'bat');
460+
assert.strictEqual(rli.historyIndex, 0);
461+
// Activate the substring history search.
455462
fi.emit('keypress', '.', { name: 'down' }); // 'bat'
456463
assert.strictEqual(rli.line, 'bat');
457-
fi.emit('keypress', '.', { name: 'down' }); // ''
458-
assert.strictEqual(rli.line, '');
464+
assert.strictEqual(rli.historyIndex, -1);
465+
// Deactivate substring history search.
466+
fi.emit('keypress', '.', { name: 'backspace' }); // 'ba'
467+
assert.strictEqual(rli.historyIndex, -1);
468+
assert.strictEqual(rli.line, 'ba');
469+
// Activate the substring history search.
470+
fi.emit('keypress', '.', { name: 'down' }); // 'ba'
471+
assert.strictEqual(rli.historyIndex, -1);
472+
assert.strictEqual(rli.line, 'ba');
473+
fi.emit('keypress', '.', { name: 'down' }); // 'ba'
474+
assert.strictEqual(rli.historyIndex, -1);
475+
assert.strictEqual(rli.line, 'ba');
476+
fi.emit('keypress', '.', { name: 'up' }); // 'bat'
477+
assert.strictEqual(rli.historyIndex, 0);
478+
assert.strictEqual(rli.line, 'bat');
479+
fi.emit('keypress', '.', { name: 'up' }); // 'bar'
480+
assert.strictEqual(rli.historyIndex, 1);
481+
assert.strictEqual(rli.line, 'bar');
482+
fi.emit('keypress', '.', { name: 'up' }); // 'baz'
483+
assert.strictEqual(rli.historyIndex, 2);
484+
assert.strictEqual(rli.line, 'baz');
485+
fi.emit('keypress', '.', { name: 'up' }); // 'baz'
486+
assert.strictEqual(rli.historyIndex, 2);
487+
assert.strictEqual(rli.line, 'baz');
488+
fi.emit('keypress', '.', { name: 'up' }); // 'baz'
489+
assert.strictEqual(rli.historyIndex, 2);
490+
assert.strictEqual(rli.line, 'baz');
459491
rli.close();
460492
}
461493

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

+66-7
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ const tests = [
7878
},
7979
{
8080
env: { NODE_REPL_HISTORY: defaultHistoryPath },
81+
checkTotal: true,
8182
test: [UP, UP, UP, UP, UP, DOWN, DOWN, DOWN, DOWN],
8283
expected: [prompt,
8384
`${prompt}Array(100).fill(1).map((e, i) => i ** 2)`,
@@ -102,6 +103,52 @@ const tests = [
102103
'1225, 1296, 1369, 1444, 1521, 1600, 1681, 1764, 1849, 1936,' +
103104
' 2025, 2116, 2209,...',
104105
prompt].filter((e) => typeof e === 'string'),
106+
clean: false
107+
},
108+
{ // Creates more history entries to navigate through.
109+
env: { NODE_REPL_HISTORY: defaultHistoryPath },
110+
test: [
111+
'555 + 909', ENTER, // Add a duplicate to the history set.
112+
'const foo = true', ENTER,
113+
'555n + 111n', ENTER,
114+
'5 + 5', ENTER,
115+
'55 - 13 === 42', ENTER
116+
],
117+
expected: [],
118+
clean: false
119+
},
120+
{
121+
env: { NODE_REPL_HISTORY: defaultHistoryPath },
122+
checkTotal: true,
123+
preview: false,
124+
showEscapeCodes: true,
125+
test: [
126+
'55', UP, UP, UP, UP, UP, UP, ENTER
127+
],
128+
expected: [
129+
'\x1B[1G', '\x1B[0J', prompt, '\x1B[3G',
130+
// '55'
131+
'5', '5',
132+
// UP
133+
'\x1B[1G', '\x1B[0J',
134+
'> 55 - 13 === 42', '\x1B[17G',
135+
// UP - skipping 5 + 5
136+
'\x1B[1G', '\x1B[0J',
137+
'> 555n + 111n', '\x1B[14G',
138+
// UP - skipping const foo = true
139+
'\x1B[1G', '\x1B[0J',
140+
'> 555 + 909', '\x1B[12G',
141+
// UP - matching the identical history entry again.
142+
'\x1B[1G', '\x1B[0J',
143+
'> 555 + 909',
144+
// UP, UP, ENTER. UPs at the end of the history have no effect.
145+
'\x1B[12G',
146+
'\r\n',
147+
'1464\n',
148+
'\x1B[1G', '\x1B[0J',
149+
'> ', '\x1B[3G',
150+
'\r\n'
151+
],
105152
clean: true
106153
},
107154
{
@@ -190,7 +237,7 @@ const tests = [
190237
'\x1B[1B', '\x1B[2K', '\x1B[1A',
191238
// 6. Backspace
192239
'\x1B[1G', '\x1B[0J',
193-
prompt, '\x1B[3G'
240+
prompt, '\x1B[3G', '\r\n'
194241
],
195242
clean: true
196243
},
@@ -259,6 +306,11 @@ const tests = [
259306
// 10. Word right. Cleanup
260307
'\x1B[0K', '\x1B[3G', '\x1B[7C', ' // n', '\x1B[10G',
261308
'\x1B[0K',
309+
// 11. ENTER
310+
'\r\n',
311+
'Uncaught ReferenceError: functio is not defined\n',
312+
'\x1B[1G', '\x1B[0J',
313+
prompt, '\x1B[3G', '\r\n'
262314
],
263315
clean: true
264316
},
@@ -300,6 +352,7 @@ const tests = [
300352
prompt,
301353
's',
302354
' // Always visible',
355+
prompt,
303356
],
304357
clean: true
305358
}
@@ -330,8 +383,8 @@ function runTest() {
330383
setImmediate(runTestWrap, true);
331384
return;
332385
}
333-
334386
const lastChunks = [];
387+
let i = 0;
335388

336389
REPL.createInternalRepl(opts.env, {
337390
input: new ActionStream(),
@@ -344,19 +397,20 @@ function runTest() {
344397
return next();
345398
}
346399

347-
lastChunks.push(inspect(output));
400+
lastChunks.push(output);
348401

349-
if (expected.length) {
402+
if (expected.length && !opts.checkTotal) {
350403
try {
351-
assert.strictEqual(output, expected[0]);
404+
assert.strictEqual(output, expected[i]);
352405
} catch (e) {
353406
console.error(`Failed test # ${numtests - tests.length}`);
354407
console.error('Last outputs: ' + inspect(lastChunks, {
355408
breakLength: 5, colors: true
356409
}));
357410
throw e;
358411
}
359-
expected.shift();
412+
// TODO(BridgeAR): Auto close on last chunk!
413+
i++;
360414
}
361415

362416
next();
@@ -365,6 +419,7 @@ function runTest() {
365419
completer: opts.completer,
366420
prompt,
367421
useColors: false,
422+
preview: opts.preview,
368423
terminal: true
369424
}, function(err, repl) {
370425
if (err) {
@@ -376,9 +431,13 @@ function runTest() {
376431
if (opts.clean)
377432
cleanupTmpFile();
378433

379-
if (expected.length !== 0) {
434+
if (opts.checkTotal) {
435+
assert.deepStrictEqual(lastChunks, expected);
436+
} else if (expected.length !== i) {
437+
console.error(tests[numtests - tests.length - 1]);
380438
throw new Error(`Failed test # ${numtests - tests.length}`);
381439
}
440+
382441
setImmediate(runTestWrap, true);
383442
});
384443

test/parallel/test-repl-reverse-search.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -309,10 +309,9 @@ function runTest() {
309309

310310
lastChunks.push(output);
311311

312-
if (expected.length) {
312+
if (expected.length && !opts.checkTotal) {
313313
try {
314-
if (!opts.checkTotal)
315-
assert.strictEqual(output, expected[i]);
314+
assert.strictEqual(output, expected[i]);
316315
} catch (e) {
317316
console.error(`Failed test # ${numtests - tests.length}`);
318317
console.error('Last outputs: ' + inspect(lastChunks, {
@@ -342,7 +341,8 @@ function runTest() {
342341

343342
if (opts.checkTotal) {
344343
assert.deepStrictEqual(lastChunks, expected);
345-
} else if (expected.length !== 0) {
344+
} else if (expected.length !== i) {
345+
console.error(tests[numtests - tests.length - 1]);
346346
throw new Error(`Failed test # ${numtests - tests.length}`);
347347
}
348348

0 commit comments

Comments
 (0)