Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

repl,readline: improve unicode support and tab completion #31288

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 2 additions & 7 deletions lib/internal/readline/utils.js
Expand Up @@ -136,8 +136,7 @@ if (internalBinding('config').hasIntl) {
(code >= 0x1b000 && code <= 0x1b001) ||
// Enclosed Ideographic Supplement
(code >= 0x1f200 && code <= 0x1f251) ||
// Miscellaneous Symbols and Pictographs 0x1f300 - 0x1f5ff
// Emoticons 0x1f600 - 0x1f64f
// Miscellaneous Symbols and Pictographs .. Emoticons
(code >= 0x1f300 && code <= 0x1f64f) ||
// CJK Unified Ideographs Extension B .. Tertiary Ideographic Plane
(code >= 0x20000 && code <= 0x3fffd)
Expand All @@ -163,7 +162,6 @@ function stripVTControlCharacters(str) {
return str.replace(ansi, '');
}


/*
Some patterns seen in terminal key escape codes, derived from combos seen
at http://www.midnight-commander.org/browser/lib/tty/key.c
Expand Down Expand Up @@ -450,7 +448,7 @@ function* emitKeys(stream) {
if (s.length !== 0 && (key.name !== undefined || escaped)) {
/* Named character or sequence */
stream.emit('keypress', escaped ? undefined : s, key);
} else if (s.length === 1) {
} else if (charLengthAt(s, 0) === s.length) {
/* Single unnamed character, e.g. "." */
stream.emit('keypress', s, key);
}
Expand All @@ -460,9 +458,6 @@ function* emitKeys(stream) {

// This runs in O(n log n).
function commonPrefix(strings) {
if (!strings || strings.length === 0) {
return '';
}
if (strings.length === 1) {
return strings[0];
}
Expand Down
6 changes: 2 additions & 4 deletions lib/internal/repl/utils.js
Expand Up @@ -320,10 +320,8 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
// Enter should automatically add the suffix to the current line as long as
// escape was not pressed. We might even remove the preview in case any
// cursor movement is triggered.
if (typeof repl.completer === 'function') {
const insertPreview = false;
showCompletionPreview(repl.line, insertPreview);
}
const insertPreview = false;
showCompletionPreview(repl.line, insertPreview);

// Do not preview if the command is buffered.
if (repl[bufferSymbol]) {
Expand Down
152 changes: 60 additions & 92 deletions lib/readline.js
Expand Up @@ -494,81 +494,67 @@ Interface.prototype._insertString = function(c) {
};

Interface.prototype._tabComplete = function(lastKeypressWasTab) {
const self = this;

self.pause();
self.completer(self.line.slice(0, self.cursor), function onComplete(err, rv) {
self.resume();
this.pause();
this.completer(this.line.slice(0, this.cursor), (err, value) => {
this.resume();

if (err) {
self._writeToOutput(`Tab completion error: ${inspect(err)}`);
this._writeToOutput(`Tab completion error: ${inspect(err)}`);
return;
}

// Result and the text that was completed.
const [completions, completeOn] = rv;
const [completions, completeOn] = value;

if (!completions || completions.length === 0) {
return;
}

// Apply/show completions.
if (lastKeypressWasTab) {
self._writeToOutput('\r\n');
const width = completions.reduce((a, b) => {
return a.length > b.length ? a : b;
}).length + 2; // 2 space padding
let maxColumns = MathFloor(self.columns / width);
if (!maxColumns || maxColumns === Infinity) {
maxColumns = 1;
}
let group = [];
for (const c of completions) {
if (c === '') {
handleGroup(self, group, width, maxColumns);
group = [];
} else {
group.push(c);
}
}
handleGroup(self, group, width, maxColumns);
}

// If there is a common prefix to all matches, then apply that portion.
const f = completions.filter((e) => e);
const prefix = commonPrefix(f);
const prefix = commonPrefix(completions.filter((e) => e !== ''));
if (prefix.length > completeOn.length) {
self._insertString(prefix.slice(completeOn.length));
this._insertString(prefix.slice(completeOn.length));
return;
}

self._refreshLine();
});
};
if (!lastKeypressWasTab) {
return;
}

// this = Interface instance
function handleGroup(self, group, width, maxColumns) {
if (group.length === 0) {
return;
}
const minRows = MathCeil(group.length / maxColumns);
for (let row = 0; row < minRows; row++) {
for (let col = 0; col < maxColumns; col++) {
const idx = row * maxColumns + col;
if (idx >= group.length) {
break;
// Apply/show completions.
const completionsWidth = completions.map((e) => getStringWidth(e));
const width = MathMax(...completionsWidth) + 2; // 2 space padding
let maxColumns = MathFloor(this.columns / width) || 1;
if (maxColumns === Infinity) {
maxColumns = 1;
}
let output = '\r\n';
let lineIndex = 0;
let whitespace = 0;
for (let i = 0; i < completions.length; i++) {
const completion = completions[i];
if (completion === '' || lineIndex === maxColumns) {
output += '\r\n';
lineIndex = 0;
whitespace = 0;
} else {
output += ' '.repeat(whitespace);
}
const item = group[idx];
self._writeToOutput(item);
if (col < maxColumns - 1) {
for (let s = 0; s < width - item.length; s++) {
self._writeToOutput(' ');
}
if (completion !== '') {
output += completion;
whitespace = width - completionsWidth[i];
lineIndex++;
} else {
output += '\r\n';
}
}
self._writeToOutput('\r\n');
}
self._writeToOutput('\r\n');
}
if (lineIndex !== 0) {
output += '\r\n\r\n';
}
this._writeToOutput(output);
this._refreshLine();
});
};

Interface.prototype._wordLeft = function() {
if (this.cursor > 0) {
Expand Down Expand Up @@ -749,27 +735,13 @@ Interface.prototype._getDisplayPos = function(str) {
return { cols, rows };
};


// Returns current cursor's position and line
Interface.prototype.getCursorPos = function() {
const columns = this.columns;
const strBeforeCursor = this._prompt + this.line.substring(0, this.cursor);
const dispPos = this._getDisplayPos(strBeforeCursor);
let cols = dispPos.cols;
let rows = dispPos.rows;
// If the cursor is on a full-width character which steps over the line,
// move the cursor to the beginning of the next line.
if (cols + 1 === columns &&
this.cursor < this.line.length &&
getStringWidth(this.line[this.cursor]) > 1) {
rows++;
cols = 0;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our cursor can't be between two code points. We move the cursor by the correct length since a while.

return { cols, rows };
return this._getDisplayPos(strBeforeCursor);
};
Interface.prototype._getCursorPos = Interface.prototype.getCursorPos;


// This function moves cursor dx places to the right
// (-dx for left) and refreshes the line if it is needed.
Interface.prototype._moveCursor = function(dx) {
Expand Down Expand Up @@ -1125,51 +1097,47 @@ Interface.prototype[SymbolAsyncIterator] = function() {
* accepts a readable Stream instance and makes it emit "keypress" events
*/

function emitKeypressEvents(stream, iface) {
function emitKeypressEvents(stream, iface = {}) {
if (stream[KEYPRESS_DECODER]) return;

stream[KEYPRESS_DECODER] = new StringDecoder('utf8');

stream[ESCAPE_DECODER] = emitKeys(stream);
stream[ESCAPE_DECODER].next();

const escapeCodeTimeout = () => stream[ESCAPE_DECODER].next('');
const triggerEscape = () => stream[ESCAPE_DECODER].next('');
const { escapeCodeTimeout = ESCAPE_CODE_TIMEOUT } = iface;
let timeoutId;

function onData(b) {
function onData(input) {
if (stream.listenerCount('keypress') > 0) {
const r = stream[KEYPRESS_DECODER].write(b);
if (r) {
const string = stream[KEYPRESS_DECODER].write(input);
if (string) {
clearTimeout(timeoutId);

let escapeTimeout = ESCAPE_CODE_TIMEOUT;

if (iface) {
iface._sawKeyPress = r.length === 1;
escapeTimeout = iface.escapeCodeTimeout;
}
// This supports characters of length 2.
iface._sawKeyPress = charLengthAt(string, 0) === string.length;
iface.isCompletionEnabled = false;

for (let i = 0; i < r.length; i++) {
if (r[i] === '\t' && typeof r[i + 1] === 'string' && iface) {
iface.isCompletionEnabled = false;
let length = 0;
for (const character of string) {
length += character.length;
if (length === string.length) {
iface.isCompletionEnabled = true;
}

try {
stream[ESCAPE_DECODER].next(r[i]);
stream[ESCAPE_DECODER].next(character);
// Escape letter at the tail position
if (r[i] === kEscape && i + 1 === r.length) {
timeoutId = setTimeout(escapeCodeTimeout, escapeTimeout);
if (length === string.length && character === kEscape) {
timeoutId = setTimeout(triggerEscape, escapeCodeTimeout);
}
} catch (err) {
// If the generator throws (it could happen in the `keypress`
// event), we need to restart it.
stream[ESCAPE_DECODER] = emitKeys(stream);
stream[ESCAPE_DECODER].next();
throw err;
} finally {
if (iface) {
iface.isCompletionEnabled = true;
}
}
}
}
Expand Down
12 changes: 5 additions & 7 deletions lib/repl.js
Expand Up @@ -1361,15 +1361,13 @@ REPLServer.prototype.completeOnEditorMode = (callback) => (err, results) => {
if (err) return callback(err);

const [completions, completeOn = ''] = results;
const prefixLength = completeOn.length;
let result = completions.filter((v) => v);

if (prefixLength === 0) return callback(null, [[], completeOn]);

const isNotEmpty = (v) => v.length > 0;
const trimCompleteOnPrefix = (v) => v.substring(prefixLength);
const data = completions.filter(isNotEmpty).map(trimCompleteOnPrefix);
if (completeOn && result.length !== 0) {
result = [commonPrefix(result)];
}

callback(null, [[`${completeOn}${commonPrefix(data)}`], completeOn]);
callback(null, [result, completeOn]);
};

REPLServer.prototype.defineCommand = function(keyword, cmd) {
Expand Down
68 changes: 68 additions & 0 deletions test/parallel/test-readline-tab-complete.js
@@ -0,0 +1,68 @@
'use strict';

// Flags: --expose_internals

const common = require('../common');
const readline = require('readline');
const assert = require('assert');
const EventEmitter = require('events').EventEmitter;
const { getStringWidth } = require('internal/readline/utils');

// This test verifies that the tab completion supports unicode and the writes
// are limited to the minimum.
[
'あ',
'𐐷',
'🐕'
].forEach((char) => {
[true, false].forEach((lineBreak) => {
const completer = (line) => [
[
'First group',
'',
`${char}${'a'.repeat(10)}`, `${char}${'b'.repeat(10)}`, char.repeat(11),
],
line
];

let output = '';
const width = getStringWidth(char) - 1;

class FakeInput extends EventEmitter {
columns = ((width + 1) * 10 + (lineBreak ? 0 : 10)) * 3

write = common.mustCall((data) => {
output += data;
}, 6)

resume() {}
pause() {}
end() {}
}

const fi = new FakeInput();
const rli = new readline.Interface({
input: fi,
output: fi,
terminal: true,
completer: completer
});

const last = '\r\nFirst group\r\n\r\n' +
`${char}${'a'.repeat(10)}${' '.repeat(2 + width * 10)}` +
`${char}${'b'.repeat(10)}` +
(lineBreak ? '\r\n' : ' '.repeat(2 + width * 10)) +
`${char.repeat(11)}\r\n` +
`\r\n\u001b[1G\u001b[0J> ${char}\u001b[${4 + width}G`;

const expectations = [char, '', last];

rli.on('line', common.mustNotCall());
for (const character of `${char}\t\t`) {
fi.emit('data', character);
assert.strictEqual(output, expectations.shift());
output = '';
}
rli.close();
});
});