mirror of
https://github.com/nodejs/node.git
synced 2024-12-01 16:10:02 +01:00
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: https://github.com/iojs/io.js/pull/1601 Fixes: https://github.com/iojs/io.js/issues/1403 Reviewed-By: Jeremiah Senkpiel <fishrock123@rocketmail.com> Reviewed-By: Roman Reiss <me@silverwind.io>
This commit is contained in:
parent
64d3210c98
commit
aed6bce906
@ -7,6 +7,7 @@ ecmaFeatures:
|
||||
templateStrings: true
|
||||
octalLiterals: true
|
||||
binaryLiterals: true
|
||||
generators: true
|
||||
|
||||
rules:
|
||||
# Possible Errors
|
||||
|
277
lib/readline.js
277
lib/readline.js
@ -893,15 +893,25 @@ exports.Interface = Interface;
|
||||
* accepts a readable Stream instance and makes it emit "keypress" events
|
||||
*/
|
||||
|
||||
const KEYPRESS_DECODER = Symbol('keypress-decoder');
|
||||
const ESCAPE_DECODER = Symbol('escape-decoder');
|
||||
|
||||
function emitKeypressEvents(stream) {
|
||||
if (stream._keypressDecoder) return;
|
||||
if (stream[KEYPRESS_DECODER]) return;
|
||||
var StringDecoder = require('string_decoder').StringDecoder; // lazy load
|
||||
stream._keypressDecoder = new StringDecoder('utf8');
|
||||
stream[KEYPRESS_DECODER] = new StringDecoder('utf8');
|
||||
|
||||
stream[ESCAPE_DECODER] = emitKeys(stream);
|
||||
stream[ESCAPE_DECODER].next();
|
||||
|
||||
function onData(b) {
|
||||
if (EventEmitter.listenerCount(stream, 'keypress') > 0) {
|
||||
var r = stream._keypressDecoder.write(b);
|
||||
if (r) emitKeys(stream, r);
|
||||
var r = stream[KEYPRESS_DECODER].write(b);
|
||||
if (r) {
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
stream[ESCAPE_DECODER].next(r[i]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Nobody's watching anyway
|
||||
stream.removeListener('data', onData);
|
||||
@ -954,102 +964,130 @@ exports.emitKeypressEvents = emitKeypressEvents;
|
||||
|
||||
// Regexes used for ansi escape code splitting
|
||||
const metaKeyCodeReAnywhere = /(?:\x1b)([a-zA-Z0-9])/;
|
||||
const metaKeyCodeRe = new RegExp('^' + metaKeyCodeReAnywhere.source + '$');
|
||||
const functionKeyCodeReAnywhere = new RegExp('(?:\x1b+)(O|N|\\[|\\[\\[)(?:' + [
|
||||
'(\\d+)(?:;(\\d+))?([~^$])',
|
||||
'(?:M([@ #!a`])(.)(.))', // mouse
|
||||
'(?:1;)?(\\d+)?([a-zA-Z])'
|
||||
].join('|') + ')');
|
||||
const functionKeyCodeRe = new RegExp('^' + functionKeyCodeReAnywhere.source);
|
||||
const escapeCodeReAnywhere = new RegExp([
|
||||
functionKeyCodeReAnywhere.source, metaKeyCodeReAnywhere.source, /\x1b./.source
|
||||
].join('|'));
|
||||
|
||||
function emitKeys(stream, s) {
|
||||
if (s instanceof Buffer) {
|
||||
if (s[0] > 127 && s[1] === undefined) {
|
||||
s[0] -= 128;
|
||||
s = '\x1b' + s.toString(stream.encoding || 'utf-8');
|
||||
} else {
|
||||
s = s.toString(stream.encoding || 'utf-8');
|
||||
|
||||
function* emitKeys(stream) {
|
||||
while (true) {
|
||||
var ch = yield;
|
||||
var s = ch;
|
||||
var escaped = false;
|
||||
var key = {
|
||||
sequence: null,
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false
|
||||
};
|
||||
|
||||
if (ch === '\x1b') {
|
||||
escaped = true;
|
||||
s += (ch = yield);
|
||||
|
||||
if (ch === '\x1b') {
|
||||
s += (ch = yield);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var buffer = [];
|
||||
var match;
|
||||
while (match = escapeCodeReAnywhere.exec(s)) {
|
||||
buffer = buffer.concat(s.slice(0, match.index).split(''));
|
||||
buffer.push(match[0]);
|
||||
s = s.slice(match.index + match[0].length);
|
||||
}
|
||||
buffer = buffer.concat(s.split(''));
|
||||
|
||||
buffer.forEach(function(s) {
|
||||
var ch,
|
||||
key = {
|
||||
sequence: s,
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false
|
||||
},
|
||||
parts;
|
||||
|
||||
if (s === '\r') {
|
||||
// carriage return
|
||||
key.name = 'return';
|
||||
|
||||
} else if (s === '\n') {
|
||||
// enter, should have been called linefeed
|
||||
key.name = 'enter';
|
||||
|
||||
} else if (s === '\t') {
|
||||
// tab
|
||||
key.name = 'tab';
|
||||
|
||||
} else if (s === '\b' || s === '\x7f' ||
|
||||
s === '\x1b\x7f' || s === '\x1b\b') {
|
||||
// backspace or ctrl+h
|
||||
key.name = 'backspace';
|
||||
key.meta = (s.charAt(0) === '\x1b');
|
||||
|
||||
} else if (s === '\x1b' || s === '\x1b\x1b') {
|
||||
// escape key
|
||||
key.name = 'escape';
|
||||
key.meta = (s.length === 2);
|
||||
|
||||
} else if (s === ' ' || s === '\x1b ') {
|
||||
key.name = 'space';
|
||||
key.meta = (s.length === 2);
|
||||
|
||||
} else if (s.length === 1 && s <= '\x1a') {
|
||||
// ctrl+letter
|
||||
key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1);
|
||||
key.ctrl = true;
|
||||
|
||||
} else if (s.length === 1 && s >= 'a' && s <= 'z') {
|
||||
// lowercase letter
|
||||
key.name = s;
|
||||
|
||||
} else if (s.length === 1 && s >= 'A' && s <= 'Z') {
|
||||
// shift+letter
|
||||
key.name = s.toLowerCase();
|
||||
key.shift = true;
|
||||
|
||||
} else if (parts = metaKeyCodeRe.exec(s)) {
|
||||
// meta+character key
|
||||
key.name = parts[1].toLowerCase();
|
||||
key.meta = true;
|
||||
key.shift = /^[A-Z]$/.test(parts[1]);
|
||||
|
||||
} else if (parts = functionKeyCodeRe.exec(s)) {
|
||||
if (escaped && (ch === 'O' || ch === '[')) {
|
||||
// ansi escape sequence
|
||||
var code = ch;
|
||||
var modifier = 0;
|
||||
|
||||
// reassemble the key code leaving out leading \x1b's,
|
||||
// the modifier key bitflag and any meaningless "1;" sequence
|
||||
var code = (parts[1] || '') + (parts[2] || '') +
|
||||
(parts[4] || '') + (parts[9] || ''),
|
||||
modifier = (parts[3] || parts[8] || 1) - 1;
|
||||
if (ch === 'O') {
|
||||
// ESC O letter
|
||||
// ESC O modifier letter
|
||||
s += (ch = yield);
|
||||
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
modifier = (ch >> 0) - 1;
|
||||
s += (ch = yield);
|
||||
}
|
||||
|
||||
code += ch;
|
||||
|
||||
} else if (ch === '[') {
|
||||
// ESC [ letter
|
||||
// ESC [ modifier letter
|
||||
// ESC [ [ modifier letter
|
||||
// ESC [ [ num char
|
||||
s += (ch = yield);
|
||||
|
||||
if (ch === '[') {
|
||||
// \x1b[[A
|
||||
// ^--- escape codes might have a second bracket
|
||||
code += ch;
|
||||
s += (ch = yield);
|
||||
}
|
||||
|
||||
/*
|
||||
* Here and later we try to buffer just enough data to get
|
||||
* a complete ascii sequence.
|
||||
*
|
||||
* We have basically two classes of ascii characters to process:
|
||||
*
|
||||
*
|
||||
* 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 }
|
||||
*
|
||||
* This particular example is featuring Ctrl+F12 in xterm.
|
||||
*
|
||||
* - `;5` part is optional, e.g. it could be `\x1b[24~`
|
||||
* - first part can contain one or two digits
|
||||
*
|
||||
* So the generic regexp is like /^\d\d?(;\d)?[~^$]$/
|
||||
*
|
||||
*
|
||||
* 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 }
|
||||
*
|
||||
* This particular example is featuring Ctrl+Home in xterm.
|
||||
*
|
||||
* - `1;5` part is optional, e.g. it could be `\x1b[H`
|
||||
* - `1;` part is optional, e.g. it could be `\x1b[5H`
|
||||
*
|
||||
* So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/
|
||||
*
|
||||
*/
|
||||
const cmdStart = s.length - 1;
|
||||
|
||||
// skip one or two leading digits
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
s += (ch = yield);
|
||||
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
s += (ch = yield);
|
||||
}
|
||||
}
|
||||
|
||||
// skip modifier
|
||||
if (ch === ';') {
|
||||
s += (ch = yield);
|
||||
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
s += (ch = yield);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* We buffered enough data, now trying to extract code
|
||||
* and modifier from it
|
||||
*/
|
||||
const cmd = s.slice(cmdStart);
|
||||
var match;
|
||||
|
||||
if ((match = cmd.match(/^(\d\d?)(;(\d))?([~^$])$/))) {
|
||||
code += match[1] + match[4];
|
||||
modifier = (match[3] || 1) - 1;
|
||||
} else if ((match = cmd.match(/^((\d;)?(\d))?([A-Za-z])$/))) {
|
||||
code += match[4];
|
||||
modifier = (match[3] || 1) - 1;
|
||||
} else {
|
||||
code += cmd;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the key modifier
|
||||
key.ctrl = !!(modifier & 4);
|
||||
@ -1152,23 +1190,58 @@ function emitKeys(stream, s) {
|
||||
/* misc. */
|
||||
case '[Z': key.name = 'tab'; key.shift = true; break;
|
||||
default: key.name = 'undefined'; break;
|
||||
|
||||
}
|
||||
|
||||
} else if (ch === '\r') {
|
||||
// carriage return
|
||||
key.name = 'return';
|
||||
|
||||
} else if (ch === '\n') {
|
||||
// enter, should have been called linefeed
|
||||
key.name = 'enter';
|
||||
|
||||
} else if (ch === '\t') {
|
||||
// tab
|
||||
key.name = 'tab';
|
||||
|
||||
} else if (ch === '\b' || ch === '\x7f') {
|
||||
// backspace or ctrl+h
|
||||
key.name = 'backspace';
|
||||
key.meta = escaped;
|
||||
|
||||
} else if (ch === '\x1b') {
|
||||
// escape key
|
||||
key.name = 'escape';
|
||||
key.meta = escaped;
|
||||
|
||||
} else if (ch === ' ') {
|
||||
key.name = 'space';
|
||||
key.meta = escaped;
|
||||
|
||||
} else if (!escaped && ch <= '\x1a') {
|
||||
// ctrl+letter
|
||||
key.name = String.fromCharCode(ch.charCodeAt(0) + 'a'.charCodeAt(0) - 1);
|
||||
key.ctrl = true;
|
||||
|
||||
} else if (/^[0-9A-Za-z]$/.test(ch)) {
|
||||
// letter, number, shift+letter
|
||||
key.name = ch.toLowerCase();
|
||||
key.shift = /^[A-Z]$/.test(ch);
|
||||
key.meta = escaped;
|
||||
}
|
||||
|
||||
// Don't emit a key if no name was found
|
||||
if (key.name === undefined) {
|
||||
key = undefined;
|
||||
}
|
||||
key.sequence = s;
|
||||
|
||||
if (s.length === 1) {
|
||||
ch = s;
|
||||
if (key.name !== undefined) {
|
||||
/* Named character or sequence */
|
||||
stream.emit('keypress', escaped ? undefined : s, key);
|
||||
} else if (s.length === 1) {
|
||||
/* Single unnamed character, e.g. "." */
|
||||
stream.emit('keypress', s);
|
||||
} else {
|
||||
/* Unrecognized or broken escape sequence, don't emit anything */
|
||||
}
|
||||
|
||||
if (key || ch) {
|
||||
stream.emit('keypress', ch, key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -191,31 +191,6 @@ function isWarned(emitter) {
|
||||
assert.equal(callCount, 1);
|
||||
rli.close();
|
||||
|
||||
// keypress
|
||||
[
|
||||
['a'],
|
||||
['\x1b'],
|
||||
['\x1b[31m'],
|
||||
['\x1b[31m', '\x1b[39m'],
|
||||
['\x1b[31m', 'a', '\x1b[39m', 'a']
|
||||
].forEach(function (keypresses) {
|
||||
fi = new FakeInput();
|
||||
callCount = 0;
|
||||
var remainingKeypresses = keypresses.slice();
|
||||
function keypressListener (ch, key) {
|
||||
callCount++;
|
||||
if (ch) assert(!key.code);
|
||||
assert.equal(key.sequence, remainingKeypresses.shift());
|
||||
};
|
||||
readline.emitKeypressEvents(fi);
|
||||
fi.on('keypress', keypressListener);
|
||||
fi.emit('data', keypresses.join(''));
|
||||
assert.equal(callCount, keypresses.length);
|
||||
assert.equal(remainingKeypresses.length, 0);
|
||||
fi.removeListener('keypress', keypressListener);
|
||||
fi.emit('data', ''); // removes listener
|
||||
});
|
||||
|
||||
// calling readline without `new`
|
||||
fi = new FakeInput();
|
||||
rli = readline.Interface({ input: fi, output: fi, terminal: terminal });
|
||||
|
150
test/parallel/test-readline-keys.js
Normal file
150
test/parallel/test-readline-keys.js
Normal file
@ -0,0 +1,150 @@
|
||||
var EventEmitter = require('events').EventEmitter;
|
||||
var PassThrough = require('stream').PassThrough;
|
||||
var assert = require('assert');
|
||||
var inherits = require('util').inherits;
|
||||
var extend = require('util')._extend;
|
||||
var Interface = require('readline').Interface;
|
||||
|
||||
|
||||
function FakeInput() {
|
||||
PassThrough.call(this);
|
||||
}
|
||||
inherits(FakeInput, PassThrough);
|
||||
|
||||
|
||||
var fi = new FakeInput();
|
||||
var fo = new FakeInput();
|
||||
var rli = new Interface({ input: fi, output: fo, terminal: true });
|
||||
|
||||
var keys = [];
|
||||
fi.on('keypress', function (s, k) {
|
||||
keys.push(k);
|
||||
});
|
||||
|
||||
|
||||
function addTest(sequences, expectedKeys) {
|
||||
if (!Array.isArray(sequences)) {
|
||||
sequences = [ sequences ];
|
||||
}
|
||||
|
||||
if (!Array.isArray(expectedKeys)) {
|
||||
expectedKeys = [ expectedKeys ];
|
||||
}
|
||||
|
||||
expectedKeys = expectedKeys.map(function (k) {
|
||||
return k ? extend({ ctrl: false, meta: false, shift: false }, k) : k;
|
||||
});
|
||||
|
||||
keys = [];
|
||||
|
||||
sequences.forEach(function (sequence) {
|
||||
fi.write(sequence);
|
||||
});
|
||||
assert.deepStrictEqual(keys, expectedKeys);
|
||||
}
|
||||
|
||||
// regular alphanumerics
|
||||
addTest('io.JS', [
|
||||
{ name: 'i', sequence: 'i' },
|
||||
{ name: 'o', sequence: 'o' },
|
||||
undefined, // emitted as `emit('keypress', '.', undefined)`
|
||||
{ name: 'j', sequence: 'J', shift: true },
|
||||
{ name: 's', sequence: 'S', shift: true },
|
||||
]);
|
||||
|
||||
// named characters
|
||||
addTest('\n\r\t', [
|
||||
{ name: 'enter', sequence: '\n' },
|
||||
{ name: 'return', sequence: '\r' },
|
||||
{ name: 'tab', sequence: '\t' },
|
||||
]);
|
||||
|
||||
// space and backspace
|
||||
addTest('\b\x7f\x1b\b\x1b\x7f \x1b ', [
|
||||
{ name: 'backspace', sequence: '\b' },
|
||||
{ name: 'backspace', sequence: '\x7f' },
|
||||
{ name: 'backspace', sequence: '\x1b\b', meta: true },
|
||||
{ name: 'backspace', sequence: '\x1b\x7f', meta: true },
|
||||
{ name: 'space', sequence: ' ' },
|
||||
{ name: 'space', sequence: '\x1b ', meta: true },
|
||||
]);
|
||||
|
||||
// control keys
|
||||
addTest('\x01\x0b\x10', [
|
||||
{ name: 'a', sequence: '\x01', ctrl: true },
|
||||
{ name: 'k', sequence: '\x0b', ctrl: true },
|
||||
{ name: 'p', sequence: '\x10', ctrl: true },
|
||||
]);
|
||||
|
||||
// alt keys
|
||||
addTest('a\x1baA\x1bA', [
|
||||
{ name: 'a', sequence: 'a' },
|
||||
{ name: 'a', sequence: '\x1ba', meta: true },
|
||||
{ name: 'a', sequence: 'A', shift: true },
|
||||
{ name: 'a', sequence: '\x1bA', meta: true, shift: true },
|
||||
]);
|
||||
|
||||
// xterm/gnome
|
||||
addTest('\x1bOA\x1bOB', [
|
||||
{ name: 'up', sequence: '\x1bOA', code: 'OA' },
|
||||
{ name: 'down', sequence: '\x1bOB', code: 'OB' },
|
||||
]);
|
||||
|
||||
// old xterm shift-arrows
|
||||
addTest('\x1bO2A\x1bO2B', [
|
||||
{ name: 'up', sequence: '\x1bO2A', code: 'OA', shift: true },
|
||||
{ name: 'down', sequence: '\x1bO2B', code: 'OB', shift: true },
|
||||
]);
|
||||
|
||||
// gnome terminal
|
||||
addTest('\x1b[A\x1b[B\x1b[2A\x1b[2B', [
|
||||
{ name: 'up', sequence: '\x1b[A', code: '[A' },
|
||||
{ name: 'down', sequence: '\x1b[B', code: '[B' },
|
||||
{ name: 'up', sequence: '\x1b[2A', code: '[A', shift: true },
|
||||
{ name: 'down', sequence: '\x1b[2B', code: '[B', shift: true },
|
||||
]);
|
||||
|
||||
// rxvt
|
||||
addTest('\x1b[20~\x1b[2$\x1b[2^', [
|
||||
{ name: 'f9', sequence: '\x1b[20~', code: '[20~' },
|
||||
{ name: 'insert', sequence: '\x1b[2$', code: '[2$', shift: true },
|
||||
{ name: 'insert', sequence: '\x1b[2^', code: '[2^', ctrl: true },
|
||||
]);
|
||||
|
||||
// xterm + modifiers
|
||||
addTest('\x1b[20;5~\x1b[6;5^', [
|
||||
{ name: 'f9', sequence: '\x1b[20;5~', code: '[20~', ctrl: true },
|
||||
{ name: 'pagedown', sequence: '\x1b[6;5^', code: '[6^', ctrl: true },
|
||||
]);
|
||||
|
||||
addTest('\x1b[H\x1b[5H\x1b[1;5H', [
|
||||
{ name: 'home', sequence: '\x1b[H', code: '[H' },
|
||||
{ name: 'home', sequence: '\x1b[5H', code: '[H', ctrl: true },
|
||||
{ name: 'home', sequence: '\x1b[1;5H', code: '[H', ctrl: true },
|
||||
]);
|
||||
|
||||
// escape sequences broken into multiple data chunks
|
||||
addTest('\x1b[D\x1b[C\x1b[D\x1b[C'.split(''), [
|
||||
{ name: 'left', sequence: '\x1b[D', code: '[D' },
|
||||
{ name: 'right', sequence: '\x1b[C', code: '[C' },
|
||||
{ name: 'left', sequence: '\x1b[D', code: '[D' },
|
||||
{ name: 'right', sequence: '\x1b[C', code: '[C' },
|
||||
]);
|
||||
|
||||
// escape sequences mixed with regular ones
|
||||
addTest('\x1b[DD\x1b[2DD\x1b[2^D', [
|
||||
{ name: 'left', sequence: '\x1b[D', code: '[D' },
|
||||
{ name: 'd', sequence: 'D', shift: true },
|
||||
{ name: 'left', sequence: '\x1b[2D', code: '[D', shift: true },
|
||||
{ name: 'd', sequence: 'D', shift: true },
|
||||
{ name: 'insert', sequence: '\x1b[2^', code: '[2^', ctrl: true },
|
||||
{ name: 'd', sequence: 'D', shift: true },
|
||||
]);
|
||||
|
||||
// color sequences
|
||||
addTest('\x1b[31ma\x1b[39ma', [
|
||||
{ name: 'undefined', sequence: '\x1b[31m', code: '[31m' },
|
||||
{ name: 'a', sequence: 'a' },
|
||||
{ name: 'undefined', sequence: '\x1b[39m', code: '[39m' },
|
||||
{ name: 'a', sequence: 'a' },
|
||||
]);
|
Loading…
Reference in New Issue
Block a user