readline: turn emitKeys into a streaming parser
authorAlex Kocharin <alex@kocharin.ru>
Sun, 3 May 2015 18:11:33 +0000 (18:11 +0000)
committerRoman Reiss <me@silverwind.io>
Sun, 10 May 2015 02:48:50 +0000 (04:48 +0200)
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>
.eslintrc
lib/readline.js
test/parallel/test-readline-interface.js
test/parallel/test-readline-keys.js [new file with mode: 0644]

index 7295f9c..8d93ac2 100644 (file)
--- a/.eslintrc
+++ b/.eslintrc
@@ -7,6 +7,7 @@ ecmaFeatures:
   templateStrings: true
   octalLiterals: true
   binaryLiterals: true
+  generators: true
 
 rules:
   # Possible Errors
index d6ae9da..5782eb8 100644 (file)
@@ -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 */
     }
-  });
+  }
 }
 
 
index 69eb4bf..0edee31 100644 (file)
@@ -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 (file)
index 0000000..0bf2673
--- /dev/null
@@ -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' },
+]);