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`).

Fixes: nodejs#1403
  • Loading branch information
rlidwka committed May 9, 2015
1 parent 19ffb5c commit 18728cc
Show file tree
Hide file tree
Showing 3 changed files with 313 additions and 108 deletions.
244 changes: 161 additions & 83 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 @@ -965,91 +975,124 @@ 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,
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
},
parts;
};

if (s === '\r') {
// carriage return
key.name = 'return';
if (ch === '\x1b') {
escaped = true;
s += (ch = yield);

} else if (s === '\n') {
// enter, should have been called linefeed
key.name = 'enter';
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 = parseInt(ch, 10) - 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 +1195,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 18728cc

Please sign in to comment.