Skip to content

Commit c212f8e

Browse files
rlidwkaFishrock123
authored andcommitted
readline: turn emitKeys into a streaming parser
In certain environments escape sequences could be splitted into multiple chunks. For example, when user presses left arrow, `\x1b[D` sequence could appear as two keypresses (`\x1b` + `[D`). PR-URL: nodejs#1601 Fixes: nodejs#1403 Reviewed-By: Jeremiah Senkpiel <fishrock123@rocketmail.com> Reviewed-By: Roman Reiss <me@silverwind.io>
1 parent 91a9c6d commit c212f8e

4 files changed

Lines changed: 318 additions & 119 deletions

File tree

.eslintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ ecmaFeatures:
77
templateStrings: true
88
octalLiterals: true
99
binaryLiterals: true
10+
generators: true
1011

1112
rules:
1213
# Possible Errors

lib/readline.js

Lines changed: 167 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -883,15 +883,25 @@ exports.Interface = Interface;
883883
* accepts a readable Stream instance and makes it emit "keypress" events
884884
*/
885885

886+
const KEYPRESS_DECODER = Symbol('keypress-decoder');
887+
const ESCAPE_DECODER = Symbol('escape-decoder');
888+
886889
function emitKeypressEvents(stream) {
887-
if (stream._keypressDecoder) return;
890+
if (stream[KEYPRESS_DECODER]) return;
888891
var StringDecoder = require('string_decoder').StringDecoder; // lazy load
889-
stream._keypressDecoder = new StringDecoder('utf8');
892+
stream[KEYPRESS_DECODER] = new StringDecoder('utf8');
893+
894+
stream[ESCAPE_DECODER] = emitKeys(stream);
895+
stream[ESCAPE_DECODER].next();
890896

891897
function onData(b) {
892898
if (EventEmitter.listenerCount(stream, 'keypress') > 0) {
893-
var r = stream._keypressDecoder.write(b);
894-
if (r) emitKeys(stream, r);
899+
var r = stream[KEYPRESS_DECODER].write(b);
900+
if (r) {
901+
for (var i = 0; i < r.length; i++) {
902+
stream[ESCAPE_DECODER].next(r[i]);
903+
}
904+
}
895905
} else {
896906
// Nobody's watching anyway
897907
stream.removeListener('data', onData);
@@ -944,102 +954,130 @@ exports.emitKeypressEvents = emitKeypressEvents;
944954

945955
// Regexes used for ansi escape code splitting
946956
const metaKeyCodeReAnywhere = /(?:\x1b)([a-zA-Z0-9])/;
947-
const metaKeyCodeRe = new RegExp('^' + metaKeyCodeReAnywhere.source + '$');
948957
const functionKeyCodeReAnywhere = new RegExp('(?:\x1b+)(O|N|\\[|\\[\\[)(?:' + [
949958
'(\\d+)(?:;(\\d+))?([~^$])',
950959
'(?:M([@ #!a`])(.)(.))', // mouse
951960
'(?:1;)?(\\d+)?([a-zA-Z])'
952961
].join('|') + ')');
953-
const functionKeyCodeRe = new RegExp('^' + functionKeyCodeReAnywhere.source);
954-
const escapeCodeReAnywhere = new RegExp([
955-
functionKeyCodeReAnywhere.source, metaKeyCodeReAnywhere.source, /\x1b./.source
956-
].join('|'));
957-
958-
function emitKeys(stream, s) {
959-
if (s instanceof Buffer) {
960-
if (s[0] > 127 && s[1] === undefined) {
961-
s[0] -= 128;
962-
s = '\x1b' + s.toString(stream.encoding || 'utf-8');
963-
} else {
964-
s = s.toString(stream.encoding || 'utf-8');
965-
}
966-
}
967962

968-
var buffer = [];
969-
var match;
970-
while (match = escapeCodeReAnywhere.exec(s)) {
971-
buffer = buffer.concat(s.slice(0, match.index).split(''));
972-
buffer.push(match[0]);
973-
s = s.slice(match.index + match[0].length);
974-
}
975-
buffer = buffer.concat(s.split(''));
976-
977-
buffer.forEach(function(s) {
978-
var ch,
979-
key = {
980-
sequence: s,
981-
name: undefined,
982-
ctrl: false,
983-
meta: false,
984-
shift: false
985-
},
986-
parts;
987-
988-
if (s === '\r') {
989-
// carriage return
990-
key.name = 'return';
991963

992-
} else if (s === '\n') {
993-
// enter, should have been called linefeed
994-
key.name = 'enter';
964+
function* emitKeys(stream) {
965+
while (true) {
966+
var ch = yield;
967+
var s = ch;
968+
var escaped = false;
969+
var key = {
970+
sequence: null,
971+
name: undefined,
972+
ctrl: false,
973+
meta: false,
974+
shift: false
975+
};
976+
977+
if (ch === '\x1b') {
978+
escaped = true;
979+
s += (ch = yield);
980+
981+
if (ch === '\x1b') {
982+
s += (ch = yield);
983+
}
984+
}
995985

996-
} else if (s === '\t') {
997-
// tab
998-
key.name = 'tab';
986+
if (escaped && (ch === 'O' || ch === '[')) {
987+
// ansi escape sequence
988+
var code = ch;
989+
var modifier = 0;
999990

1000-
} else if (s === '\b' || s === '\x7f' ||
1001-
s === '\x1b\x7f' || s === '\x1b\b') {
1002-
// backspace or ctrl+h
1003-
key.name = 'backspace';
1004-
key.meta = (s.charAt(0) === '\x1b');
991+
if (ch === 'O') {
992+
// ESC O letter
993+
// ESC O modifier letter
994+
s += (ch = yield);
1005995

1006-
} else if (s === '\x1b' || s === '\x1b\x1b') {
1007-
// escape key
1008-
key.name = 'escape';
1009-
key.meta = (s.length === 2);
996+
if (ch >= '0' && ch <= '9') {
997+
modifier = (ch >> 0) - 1;
998+
s += (ch = yield);
999+
}
10101000

1011-
} else if (s === ' ' || s === '\x1b ') {
1012-
key.name = 'space';
1013-
key.meta = (s.length === 2);
1001+
code += ch;
10141002

1015-
} else if (s.length === 1 && s <= '\x1a') {
1016-
// ctrl+letter
1017-
key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1);
1018-
key.ctrl = true;
1003+
} else if (ch === '[') {
1004+
// ESC [ letter
1005+
// ESC [ modifier letter
1006+
// ESC [ [ modifier letter
1007+
// ESC [ [ num char
1008+
s += (ch = yield);
10191009

1020-
} else if (s.length === 1 && s >= 'a' && s <= 'z') {
1021-
// lowercase letter
1022-
key.name = s;
1010+
if (ch === '[') {
1011+
// \x1b[[A
1012+
// ^--- escape codes might have a second bracket
1013+
code += ch;
1014+
s += (ch = yield);
1015+
}
10231016

1024-
} else if (s.length === 1 && s >= 'A' && s <= 'Z') {
1025-
// shift+letter
1026-
key.name = s.toLowerCase();
1027-
key.shift = true;
1017+
/*
1018+
* Here and later we try to buffer just enough data to get
1019+
* a complete ascii sequence.
1020+
*
1021+
* We have basically two classes of ascii characters to process:
1022+
*
1023+
*
1024+
* 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 }
1025+
*
1026+
* This particular example is featuring Ctrl+F12 in xterm.
1027+
*
1028+
* - `;5` part is optional, e.g. it could be `\x1b[24~`
1029+
* - first part can contain one or two digits
1030+
*
1031+
* So the generic regexp is like /^\d\d?(;\d)?[~^$]$/
1032+
*
1033+
*
1034+
* 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 }
1035+
*
1036+
* This particular example is featuring Ctrl+Home in xterm.
1037+
*
1038+
* - `1;5` part is optional, e.g. it could be `\x1b[H`
1039+
* - `1;` part is optional, e.g. it could be `\x1b[5H`
1040+
*
1041+
* So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/
1042+
*
1043+
*/
1044+
const cmdStart = s.length - 1;
1045+
1046+
// skip one or two leading digits
1047+
if (ch >= '0' && ch <= '9') {
1048+
s += (ch = yield);
1049+
1050+
if (ch >= '0' && ch <= '9') {
1051+
s += (ch = yield);
1052+
}
1053+
}
10281054

1029-
} else if (parts = metaKeyCodeRe.exec(s)) {
1030-
// meta+character key
1031-
key.name = parts[1].toLowerCase();
1032-
key.meta = true;
1033-
key.shift = /^[A-Z]$/.test(parts[1]);
1055+
// skip modifier
1056+
if (ch === ';') {
1057+
s += (ch = yield);
10341058

1035-
} else if (parts = functionKeyCodeRe.exec(s)) {
1036-
// ansi escape sequence
1059+
if (ch >= '0' && ch <= '9') {
1060+
s += (ch = yield);
1061+
}
1062+
}
10371063

1038-
// reassemble the key code leaving out leading \x1b's,
1039-
// the modifier key bitflag and any meaningless "1;" sequence
1040-
var code = (parts[1] || '') + (parts[2] || '') +
1041-
(parts[4] || '') + (parts[9] || ''),
1042-
modifier = (parts[3] || parts[8] || 1) - 1;
1064+
/*
1065+
* We buffered enough data, now trying to extract code
1066+
* and modifier from it
1067+
*/
1068+
const cmd = s.slice(cmdStart);
1069+
var match;
1070+
1071+
if ((match = cmd.match(/^(\d\d?)(;(\d))?([~^$])$/))) {
1072+
code += match[1] + match[4];
1073+
modifier = (match[3] || 1) - 1;
1074+
} else if ((match = cmd.match(/^((\d;)?(\d))?([A-Za-z])$/))) {
1075+
code += match[4];
1076+
modifier = (match[3] || 1) - 1;
1077+
} else {
1078+
code += cmd;
1079+
}
1080+
}
10431081

10441082
// Parse the key modifier
10451083
key.ctrl = !!(modifier & 4);
@@ -1142,23 +1180,58 @@ function emitKeys(stream, s) {
11421180
/* misc. */
11431181
case '[Z': key.name = 'tab'; key.shift = true; break;
11441182
default: key.name = 'undefined'; break;
1145-
11461183
}
1147-
}
11481184

1149-
// Don't emit a key if no name was found
1150-
if (key.name === undefined) {
1151-
key = undefined;
1152-
}
1185+
} else if (ch === '\r') {
1186+
// carriage return
1187+
key.name = 'return';
1188+
1189+
} else if (ch === '\n') {
1190+
// enter, should have been called linefeed
1191+
key.name = 'enter';
1192+
1193+
} else if (ch === '\t') {
1194+
// tab
1195+
key.name = 'tab';
11531196

1154-
if (s.length === 1) {
1155-
ch = s;
1197+
} else if (ch === '\b' || ch === '\x7f') {
1198+
// backspace or ctrl+h
1199+
key.name = 'backspace';
1200+
key.meta = escaped;
1201+
1202+
} else if (ch === '\x1b') {
1203+
// escape key
1204+
key.name = 'escape';
1205+
key.meta = escaped;
1206+
1207+
} else if (ch === ' ') {
1208+
key.name = 'space';
1209+
key.meta = escaped;
1210+
1211+
} else if (!escaped && ch <= '\x1a') {
1212+
// ctrl+letter
1213+
key.name = String.fromCharCode(ch.charCodeAt(0) + 'a'.charCodeAt(0) - 1);
1214+
key.ctrl = true;
1215+
1216+
} else if (/^[0-9A-Za-z]$/.test(ch)) {
1217+
// letter, number, shift+letter
1218+
key.name = ch.toLowerCase();
1219+
key.shift = /^[A-Z]$/.test(ch);
1220+
key.meta = escaped;
11561221
}
11571222

1158-
if (key || ch) {
1159-
stream.emit('keypress', ch, key);
1223+
key.sequence = s;
1224+
1225+
if (key.name !== undefined) {
1226+
/* Named character or sequence */
1227+
stream.emit('keypress', escaped ? undefined : s, key);
1228+
} else if (s.length === 1) {
1229+
/* Single unnamed character, e.g. "." */
1230+
stream.emit('keypress', s);
1231+
} else {
1232+
/* Unrecognized or broken escape sequence, don't emit anything */
11601233
}
1161-
});
1234+
}
11621235
}
11631236

11641237

test/parallel/test-readline-interface.js

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -191,31 +191,6 @@ function isWarned(emitter) {
191191
assert.equal(callCount, 1);
192192
rli.close();
193193

194-
// keypress
195-
[
196-
['a'],
197-
['\x1b'],
198-
['\x1b[31m'],
199-
['\x1b[31m', '\x1b[39m'],
200-
['\x1b[31m', 'a', '\x1b[39m', 'a']
201-
].forEach(function (keypresses) {
202-
fi = new FakeInput();
203-
callCount = 0;
204-
var remainingKeypresses = keypresses.slice();
205-
function keypressListener (ch, key) {
206-
callCount++;
207-
if (ch) assert(!key.code);
208-
assert.equal(key.sequence, remainingKeypresses.shift());
209-
};
210-
readline.emitKeypressEvents(fi);
211-
fi.on('keypress', keypressListener);
212-
fi.emit('data', keypresses.join(''));
213-
assert.equal(callCount, keypresses.length);
214-
assert.equal(remainingKeypresses.length, 0);
215-
fi.removeListener('keypress', keypressListener);
216-
fi.emit('data', ''); // removes listener
217-
});
218-
219194
// calling readline without `new`
220195
fi = new FakeInput();
221196
rli = readline.Interface({ input: fi, output: fi, terminal: terminal });

0 commit comments

Comments
 (0)