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: #31112Fixes: #28437 Reviewed-By: Michaël Zasso <targos@protonmail.com> Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent d84c394 commit b6f4e01

7 files changed

Lines changed: 163 additions & 33 deletions

File tree

‎doc/api/repl.md‎

Lines changed: 5 additions & 4 deletions
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‎

Lines 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
constkUTF16SurrogateThreshold=0x10000;// 2 ** 16
1920
constkEscape='\x1b';
21+
constkSubstringSearch=Symbol('kSubstringSearch');
2022

2123
letgetStringWidth;
2224
letisFullWidthCodePoint;
@@ -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‎

Lines 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
typeofstring!=='string'||
647648
string===''){
648649
reset();
650+
repl[kSubstringSearch]='';
649651
}else{
650652
reset(`${input}${string}`);
651653
search();

‎lib/readline.js‎

Lines 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
constself=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+
constsearch=this[kSubstringSearch]||'';
703+
letindex=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-
}elseif(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+
constsearch=this[kSubstringSearch]||'';
722+
letindex=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
letoffset=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+
}elseif(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‎

Lines 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
constexpectedLines=['foo','bar','baz','bar','bat','bat'];
433+
// ['foo', 'baz', 'bar', bat'];
433434
letcallCount=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‎

Lines 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)=>typeofe==='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
constlastChunks=[];
387+
leti=0;
335388

336389
REPL.createInternalRepl(opts.env,{
337390
input: newActionStream(),
@@ -344,19 +397,20 @@ function runTest() {
344397
returnnext();
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
throwe;
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+
}elseif(expected.length!==i){
437+
console.error(tests[numtests-tests.length-1]);
380438
thrownewError(`Failed test # ${numtests-tests.length}`);
381439
}
440+
382441
setImmediate(runTestWrap,true);
383442
});
384443

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

Lines 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-
}elseif(expected.length!==0){
344+
}elseif(expected.length!==i){
345+
console.error(tests[numtests-tests.length-1]);
346346
thrownewError(`Failed test # ${numtests-tests.length}`);
347347
}
348348

0 commit comments

Comments
 (0)