Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit b6f4e01

Browse filesBrowse 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
Copy full SHA for b6f4e01

File tree

Expand file treeCollapse file tree

7 files changed

+163
-33
lines changed
Open diff view settings
Filter options
Expand file treeCollapse file tree

7 files changed

+163
-33
lines changed
Open diff view settings
Collapse file

‎doc/api/repl.md‎

Copy file name to clipboardExpand all lines: doc/api/repl.md
+5-4Lines changed: 5 additions & 4 deletions
  • Display the source diff
  • Display the rich diff
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

Collapse file

‎lib/internal/readline/utils.js‎

Copy file name to clipboardExpand all lines: lib/internal/readline/utils.js
+3Lines changed: 3 additions & 0 deletions
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
Collapse file

‎lib/internal/repl/utils.js‎

Copy file name to clipboardExpand all lines: lib/internal/repl/utils.js
+2Lines changed: 2 additions & 0 deletions
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();
Collapse file

‎lib/readline.js‎

Copy file name to clipboardExpand all lines: lib/readline.js
+49-16Lines changed: 49 additions & 16 deletions
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;
Collapse file

‎test/parallel/test-readline-interface.js‎

Copy file name to clipboardExpand all lines: test/parallel/test-readline-interface.js
+34-2Lines changed: 34 additions & 2 deletions
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

Collapse file

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

Copy file name to clipboardExpand all lines: test/parallel/test-repl-history-navigation.js
+66-7Lines changed: 66 additions & 7 deletions
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

Collapse file

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

Copy file name to clipboardExpand all lines: test/parallel/test-repl-reverse-search.js
+4-4Lines changed: 4 additions & 4 deletions
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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.