Skip to content

Commit

Permalink
readline: turn emitKeys into a streaming parser
Browse files Browse the repository at this point in the history
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: #1601
Fixes: #1403
Reviewed-By: Jeremiah Senkpiel <fishrock123@rocketmail.com>
Reviewed-By: Roman Reiss <me@silverwind.io>
  • Loading branch information
rlidwka authored and silverwind committed May 10, 2015
1 parent 64d3210 commit aed6bce
Show file tree
Hide file tree
Showing 4 changed files with 318 additions and 119 deletions.
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ ecmaFeatures:
templateStrings: true
octalLiterals: true
binaryLiterals: true
generators: true

rules:
# Possible Errors
Expand Down
261 changes: 167 additions & 94 deletions lib/readline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
}
}

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';
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);
}
}

} else if (s === '\t') {
// tab
key.name = 'tab';
if (escaped && (ch === 'O' || ch === '[')) {
// ansi escape sequence
var code = ch;
var modifier = 0;

} 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');
if (ch === 'O') {
// ESC O letter
// ESC O modifier letter
s += (ch = yield);

} else if (s === '\x1b' || s === '\x1b\x1b') {
// escape key
key.name = 'escape';
key.meta = (s.length === 2);
if (ch >= '0' && ch <= '9') {
modifier = (ch >> 0) - 1;
s += (ch = yield);
}

} else if (s === ' ' || s === '\x1b ') {
key.name = 'space';
key.meta = (s.length === 2);
code += ch;

} 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 (ch === '[') {
// ESC [ letter
// ESC [ modifier letter
// ESC [ [ modifier letter
// ESC [ [ num char
s += (ch = yield);

} else if (s.length === 1 && s >= 'a' && s <= 'z') {
// lowercase letter
key.name = s;
if (ch === '[') {
// \x1b[[A
// ^--- escape codes might have a second bracket
code += ch;
s += (ch = yield);
}

} else if (s.length === 1 && s >= 'A' && s <= 'Z') {
// shift+letter
key.name = s.toLowerCase();
key.shift = true;
/*
* 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);
}
}

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

} else if (parts = functionKeyCodeRe.exec(s)) {
// ansi escape sequence
if (ch >= '0' && ch <= '9') {
s += (ch = yield);
}
}

// 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;
/*
* 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);
Expand Down Expand Up @@ -1152,23 +1190,58 @@ function emitKeys(stream, s) {
/* misc. */
case '[Z': key.name = 'tab'; key.shift = true; break;
default: key.name = 'undefined'; break;

}
}

// Don't emit a key if no name was found
if (key.name === undefined) {
key = undefined;
}
} 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';

if (s.length === 1) {
ch = s;
} 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;
}

if (key || ch) {
stream.emit('keypress', ch, key);
key.sequence = 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 */
}
});
}
}


Expand Down
25 changes: 0 additions & 25 deletions test/parallel/test-readline-interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading

0 comments on commit aed6bce

Please sign in to comment.