From aed6bce9064915bda28237b1a5fbf7fcdbf439ef Mon Sep 17 00:00:00 2001 From: Alex Kocharin Date: Sun, 3 May 2015 18:11:33 +0000 Subject: [PATCH] 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 Reviewed-By: Roman Reiss --- .eslintrc | 1 + lib/readline.js | 261 +++++++++++++++-------- test/parallel/test-readline-interface.js | 25 --- test/parallel/test-readline-keys.js | 150 +++++++++++++ 4 files changed, 318 insertions(+), 119 deletions(-) create mode 100644 test/parallel/test-readline-keys.js diff --git a/.eslintrc b/.eslintrc index 7295f9cd1a1ea9..8d93ac2738278e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,6 +7,7 @@ ecmaFeatures: templateStrings: true octalLiterals: true binaryLiterals: true + generators: true rules: # Possible Errors diff --git a/lib/readline.js b/lib/readline.js index d6ae9dad3d57c4..5782eb86e3eb12 100644 --- a/lib/readline.js +++ b/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'); - } - } - 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); @@ -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 */ } - }); + } } diff --git a/test/parallel/test-readline-interface.js b/test/parallel/test-readline-interface.js index 69eb4bf51992cb..0edee3190ccf26 100644 --- a/test/parallel/test-readline-interface.js +++ b/test/parallel/test-readline-interface.js @@ -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 }); diff --git a/test/parallel/test-readline-keys.js b/test/parallel/test-readline-keys.js new file mode 100644 index 00000000000000..0bf2673435f584 --- /dev/null +++ b/test/parallel/test-readline-keys.js @@ -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' }, +]);