diff --git a/doc/api/repl.md b/doc/api/repl.md index a8e89e4ef51fea..4703022d3b5ea4 100644 --- a/doc/api/repl.md +++ b/doc/api/repl.md @@ -565,8 +565,9 @@ changes: * `breakEvalOnSigint` {boolean} Stop evaluating the current piece of code when `SIGINT` is received, such as when `Ctrl+C` is pressed. This cannot be used together with a custom `eval` function. **Default:** `false`. - * `preview` {boolean} Defines if the repl prints output previews or not. - **Default:** `true`. Always `false` in case `terminal` is falsy. + * `preview` {boolean} Defines if the repl prints autocomplete and output + previews or not. **Default:** `true`. If `terminal` is falsy, then there are + no previews and the value of `preview` has no effect. * Returns: {repl.REPLServer} The `repl.start()` method creates and starts a [`repl.REPLServer`][] instance. diff --git a/lib/internal/repl/utils.js b/lib/internal/repl/utils.js index c4280c1d1fe9e2..bdc402ee08715d 100644 --- a/lib/internal/repl/utils.js +++ b/lib/internal/repl/utils.js @@ -28,6 +28,10 @@ const { moveCursor, } = require('readline'); +const { + commonPrefix +} = require('internal/readline/utils'); + const { inspect } = require('util'); const debug = require('internal/util/debuglog').debuglog('repl'); @@ -119,24 +123,103 @@ function isRecoverableError(e, code) { function setupPreview(repl, contextSymbol, bufferSymbol, active) { // Simple terminals can't handle previews. if (process.env.TERM === 'dumb' || !active) { - return { showInputPreview() {}, clearPreview() {} }; + return { showPreview() {}, clearPreview() {} }; } - let preview = null; - let lastPreview = ''; + let inputPreview = null; + let lastInputPreview = ''; + + let previewCompletionCounter = 0; + let completionPreview = null; const clearPreview = () => { - if (preview !== null) { + if (inputPreview !== null) { moveCursor(repl.output, 0, 1); clearLine(repl.output); moveCursor(repl.output, 0, -1); - lastPreview = preview; - preview = null; + lastInputPreview = inputPreview; + inputPreview = null; + } + if (completionPreview !== null) { + // Prevent cursor moves if not necessary! + const move = repl.line.length !== repl.cursor; + if (move) { + cursorTo(repl.output, repl._prompt.length + repl.line.length); + } + clearLine(repl.output, 1); + if (move) { + cursorTo(repl.output, repl._prompt.length + repl.cursor); + } + completionPreview = null; } }; + function showCompletionPreview(line, insertPreview) { + previewCompletionCounter++; + + const count = previewCompletionCounter; + + repl.completer(line, (error, data) => { + // Tab completion might be async and the result might already be outdated. + if (count !== previewCompletionCounter) { + return; + } + + if (error) { + debug('Error while generating completion preview', error); + return; + } + + // Result and the text that was completed. + const [rawCompletions, completeOn] = data; + + if (!rawCompletions || rawCompletions.length === 0) { + return; + } + + // If there is a common prefix to all matches, then apply that portion. + const completions = rawCompletions.filter((e) => e); + const prefix = commonPrefix(completions); + + // No common prefix found. + if (prefix.length <= completeOn.length) { + return; + } + + const suffix = prefix.slice(completeOn.length); + + const totalLength = repl.line.length + + repl._prompt.length + + suffix.length + + (repl.useColors ? 0 : 4); + + // TODO(BridgeAR): Fix me. This should not be necessary. See similar + // comment in `showPreview()`. + if (totalLength > repl.columns) { + return; + } + + if (insertPreview) { + repl._insertString(suffix); + return; + } + + completionPreview = suffix; + + const result = repl.useColors ? + `\u001b[90m${suffix}\u001b[39m` : + ` // ${suffix}`; + + if (repl.line.length !== repl.cursor) { + cursorTo(repl.output, repl._prompt.length + repl.line.length); + } + repl.output.write(result); + cursorTo(repl.output, repl._prompt.length + repl.cursor); + }); + } + // This returns a code preview for arbitrary input code. - function getPreviewInput(input, callback) { + function getInputPreview(input, callback) { // For similar reasons as `defaultEval`, wrap expressions starting with a // curly brace with parenthesis. if (input.startsWith('{') && !input.endsWith(';')) { @@ -184,23 +267,41 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) { }, () => callback(new ERR_INSPECTOR_NOT_AVAILABLE())); } - const showInputPreview = () => { + const showPreview = () => { // Prevent duplicated previews after a refresh. - if (preview !== null) { + if (inputPreview !== null) { return; } const line = repl.line.trim(); - // Do not preview if the command is buffered or if the line is empty. - if (repl[bufferSymbol] || line === '') { + // Do not preview in case the line only contains whitespace. + if (line === '') { + return; + } + + // Add the autocompletion preview. + // TODO(BridgeAR): Trigger the input preview after the completion preview. + // That way it's possible to trigger the input prefix including the + // potential completion suffix. To do so, we also have to change the + // behavior of `enter` and `escape`: + // 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); + } + + // Do not preview if the command is buffered. + if (repl[bufferSymbol]) { return; } - getPreviewInput(line, (error, inspected) => { + getInputPreview(line, (error, inspected) => { // Ignore the output if the value is identical to the current line and the // former preview is not identical to this preview. - if ((line === inspected && lastPreview !== inspected) || + if ((line === inspected && lastInputPreview !== inspected) || inspected === null) { return; } @@ -215,7 +316,7 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) { return; } - preview = inspected; + inputPreview = inspected; // Limit the output to maximum 250 characters. Otherwise it becomes a) // difficult to read and b) non terminal REPLs would visualize the whole @@ -235,21 +336,50 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) { repl.output.write(`\n${result}`); moveCursor(repl.output, 0, -1); - cursorTo(repl.output, repl.cursor + repl._prompt.length); + cursorTo(repl.output, repl._prompt.length + repl.cursor); }); }; + // -------------------------------------------------------------------------// + // Replace multiple interface functions. This is required to fully support // + // previews without changing readlines behavior. // + // -------------------------------------------------------------------------// + // Refresh prints the whole screen again and the preview will be removed // during that procedure. Print the preview again. This also makes sure // the preview is always correct after resizing the terminal window. - const tmpRefresh = repl._refreshLine.bind(repl); + const originalRefresh = repl._refreshLine.bind(repl); repl._refreshLine = () => { - preview = null; - tmpRefresh(); - showInputPreview(); + inputPreview = null; + originalRefresh(); + showPreview(); + }; + + let insertCompletionPreview = true; + // Insert the longest common suffix of the current input in case the user + // moves to the right while already being at the current input end. + const originalMoveCursor = repl._moveCursor.bind(repl); + repl._moveCursor = (dx) => { + const currentCursor = repl.cursor; + originalMoveCursor(dx); + if (currentCursor + dx > repl.line.length && + typeof repl.completer === 'function' && + insertCompletionPreview) { + const insertPreview = true; + showCompletionPreview(repl.line, insertPreview); + } + }; + + // This is the only function that interferes with the completion insertion. + // Monkey patch it to prevent inserting the completion when it shouldn't be. + const originalClearLine = repl.clearLine.bind(repl); + repl.clearLine = () => { + insertCompletionPreview = false; + originalClearLine(); + insertCompletionPreview = true; }; - return { showInputPreview, clearPreview }; + return { showPreview, clearPreview }; } module.exports = { diff --git a/lib/readline.js b/lib/readline.js index d471d512f485d5..daf30969408222 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -601,8 +601,11 @@ function charLengthLeft(str, i) { } function charLengthAt(str, i) { - if (str.length <= i) - return 0; + if (str.length <= i) { + // Pretend to move to the right. This is necessary to autocomplete while + // moving to the right. + return 1; + } return str.codePointAt(i) >= kUTF16SurrogateThreshold ? 2 : 1; } @@ -956,6 +959,7 @@ Interface.prototype._ttyWrite = function(s, key) { } break; + // TODO(BridgeAR): This seems broken? case 'w': // Delete backwards to a word boundary case 'backspace': this._deleteWordLeft(); diff --git a/lib/repl.js b/lib/repl.js index cd8240dd743919..24948477fea65e 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -810,7 +810,7 @@ function REPLServer(prompt, const { clearPreview, - showInputPreview + showPreview } = setupPreview( this, kContextId, @@ -821,7 +821,6 @@ function REPLServer(prompt, // Wrap readline tty to enable editor mode and pausing. const ttyWrite = self._ttyWrite.bind(self); self._ttyWrite = (d, key) => { - clearPreview(); key = key || {}; if (paused && !(self.breakEvalOnSigint && key.ctrl && key.name === 'c')) { pausedBuffer.push(['key', [d, key]]); @@ -833,14 +832,17 @@ function REPLServer(prompt, self.cursor === 0 && self.line.length === 0) { self.clearLine(); } + clearPreview(); ttyWrite(d, key); - showInputPreview(); + showPreview(); return; } // Editor mode if (key.ctrl && !key.shift) { switch (key.name) { + // TODO(BridgeAR): There should not be a special mode necessary for full + // multiline support. case 'd': // End editor mode _turnOffEditorMode(self); sawCtrlD = true; diff --git a/test/parallel/test-repl-editor.js b/test/parallel/test-repl-editor.js index 969f6172b3fb70..b340bd66313971 100644 --- a/test/parallel/test-repl-editor.js +++ b/test/parallel/test-repl-editor.js @@ -5,10 +5,11 @@ const assert = require('assert'); const repl = require('repl'); const ArrayStream = require('../common/arraystream'); -// \u001b[1G - Moves the cursor to 1st column +// \u001b[nG - Moves the cursor to n st column // \u001b[0J - Clear screen -// \u001b[3G - Moves the cursor to 3rd column +// \u001b[0K - Clear to line end const terminalCode = '\u001b[1G\u001b[0J> \u001b[3G'; +const previewCode = (str, n) => ` // ${str}\x1B[${n}G\x1B[0K`; const terminalCodeRegex = new RegExp(terminalCode.replace(/\[/g, '\\['), 'g'); function run({ input, output, event, checkTerminalCodes = true }) { @@ -17,7 +18,9 @@ function run({ input, output, event, checkTerminalCodes = true }) { stream.write = (msg) => found += msg.replace('\r', ''); - let expected = `${terminalCode}.editor\n` + + let expected = `${terminalCode}.ed${previewCode('itor', 6)}i` + + `${previewCode('tor', 7)}t${previewCode('or', 8)}o` + + `${previewCode('r', 9)}r\n` + '// Entering editor mode (^D to finish, ^C to cancel)\n' + `${input}${output}\n${terminalCode}`; diff --git a/test/parallel/test-repl-multiline.js b/test/parallel/test-repl-multiline.js index 6498923b62ecfc..f99b91c84b0a85 100644 --- a/test/parallel/test-repl-multiline.js +++ b/test/parallel/test-repl-multiline.js @@ -23,14 +23,23 @@ function run({ useColors }) { r.on('exit', common.mustCall(() => { const actual = output.split('\n'); + const firstLine = useColors ? + '\x1B[1G\x1B[0J \x1B[1Gco\x1B[90mn\x1B[39m\x1B[3G\x1B[0Knst ' + + 'fo\x1B[90mr\x1B[39m\x1B[9G\x1B[0Ko = {' : + '\x1B[1G\x1B[0J \x1B[1Gco // n\x1B[3G\x1B[0Knst ' + + 'fo // r\x1B[9G\x1B[0Ko = {'; + // Validate the output, which contains terminal escape codes. assert.strictEqual(actual.length, 6 + process.features.inspector); - assert.ok(actual[0].endsWith(input[0])); + assert.strictEqual(actual[0], firstLine); assert.ok(actual[1].includes('... ')); assert.ok(actual[1].endsWith(input[1])); assert.ok(actual[2].includes('undefined')); - assert.ok(actual[3].endsWith(input[2])); if (process.features.inspector) { + assert.ok( + actual[3].endsWith(input[2]), + `"${actual[3]}" should end with "${input[2]}"` + ); assert.ok(actual[4].includes(actual[5])); assert.strictEqual(actual[4].includes('//'), !useColors); } diff --git a/test/parallel/test-repl-preview.js b/test/parallel/test-repl-preview.js index 92e73dd245056f..65b56904cb03b9 100644 --- a/test/parallel/test-repl-preview.js +++ b/test/parallel/test-repl-preview.js @@ -72,13 +72,13 @@ async function tests(options) { '\x1B[36m[Function: foo]\x1B[39m', '\x1B[1G\x1B[0Jrepl > \x1B[8G'], ['koo', [2, 4], '[Function: koo]', - 'koo', + 'k\x1B[90moo\x1B[39m\x1B[9G\x1B[0Ko\x1B[90mo\x1B[39m\x1B[10G\x1B[0Ko', '\x1B[90m[Function: koo]\x1B[39m\x1B[1A\x1B[11G\x1B[1B\x1B[2K\x1B[1A\r', '\x1B[36m[Function: koo]\x1B[39m', '\x1B[1G\x1B[0Jrepl > \x1B[8G'], ['a', [1, 2], undefined], ['{ a: true }', [2, 3], '{ a: \x1B[33mtrue\x1B[39m }', - '{ a: true }\r', + '{ a: tru\x1B[90me\x1B[39m\x1B[16G\x1B[0Ke }\r', '{ a: \x1B[33mtrue\x1B[39m }', '\x1B[1G\x1B[0Jrepl > \x1B[8G'], ['1n + 2n', [2, 5], '\x1B[33m3n\x1B[39m', @@ -88,12 +88,12 @@ async function tests(options) { '\x1B[33m3n\x1B[39m', '\x1B[1G\x1B[0Jrepl > \x1B[8G'], ['{ a: true };', [2, 4], '\x1B[33mtrue\x1B[39m', - '{ a: true };', + '{ a: tru\x1B[90me\x1B[39m\x1B[16G\x1B[0Ke };', '\x1B[90mtrue\x1B[39m\x1B[1A\x1B[20G\x1B[1B\x1B[2K\x1B[1A\r', '\x1B[33mtrue\x1B[39m', '\x1B[1G\x1B[0Jrepl > \x1B[8G'], [' \t { a: true};', [2, 5], '\x1B[33mtrue\x1B[39m', - ' \t { a: true}', + ' \t { a: tru\x1B[90me\x1B[39m\x1B[19G\x1B[0Ke}', '\x1B[90m{ a: true }\x1B[39m\x1B[1A\x1B[21G\x1B[1B\x1B[2K\x1B[1A;', '\x1B[90mtrue\x1B[39m\x1B[1A\x1B[22G\x1B[1B\x1B[2K\x1B[1A\r', '\x1B[33mtrue\x1B[39m', diff --git a/test/parallel/test-repl-top-level-await.js b/test/parallel/test-repl-top-level-await.js index cecbd3ab4563d0..47fcb8530dee77 100644 --- a/test/parallel/test-repl-top-level-await.js +++ b/test/parallel/test-repl-top-level-await.js @@ -60,7 +60,7 @@ const testMe = repl.start({ prompt: PROMPT, stream: putIn, terminal: true, - useColors: false, + useColors: true, breakEvalOnSigint: true }); @@ -84,69 +84,99 @@ async function ordinaryTests() { 'function koo() { return Promise.resolve(4); }' ]); const testCases = [ - [ 'await Promise.resolve(0)', '0' ], - [ '{ a: await Promise.resolve(1) }', '{ a: 1 }' ], - [ '_', '// { a: 1 }\r', { line: 0 } ], + [ 'await Promise.resolve(0)', + // Auto completion preview with colors stripped. + ['awaitaititt Proroomiseisesee.resolveolvelvevee(0)\r', '0'] + ], + [ '{ a: await Promise.resolve(1) }', + // Auto completion preview with colors stripped. + ['{ a: awaitaititt Proroomiseisesee.resolveolvelvevee(1) }\r', + '{ a: 1 }'] + ], + [ '_', '{ a: 1 }\r', { line: 0 } ], [ 'let { aa, bb } = await Promise.resolve({ aa: 1, bb: 2 }), f = 5;', - 'undefined' ], - [ 'aa', ['// 1\r', '1'] ], - [ 'bb', ['// 2\r', '2'] ], - [ 'f', ['// 5\r', '5'] ], - [ 'let cc = await Promise.resolve(2)', 'undefined' ], - [ 'cc', ['// 2\r', '2'] ], - [ 'let dd;', 'undefined' ], - [ 'dd', 'undefined' ], - [ 'let [ii, { abc: { kk } }] = [0, { abc: { kk: 1 } }];', 'undefined' ], - [ 'ii', ['// 0\r', '0'] ], - [ 'kk', ['// 1\r', '1'] ], - [ 'var ll = await Promise.resolve(2);', 'undefined' ], - [ 'll', ['// 2\r', '2'] ], + [ + 'letett { aa, bb } = awaitaititt Proroomiseisesee.resolveolvelvevee' + + '({ aa: 1, bb: 2 }), f = 5;\r' + ] + ], + [ 'aa', ['1\r', '1'] ], + [ 'bb', ['2\r', '2'] ], + [ 'f', ['5\r', '5'] ], + [ 'let cc = await Promise.resolve(2)', + ['letett cc = awaitaititt Proroomiseisesee.resolveolvelvevee(2)\r'] + ], + [ 'cc', ['2\r', '2'] ], + [ 'let dd;', ['letett dd;\r'] ], + [ 'dd', ['undefined\r'] ], + [ 'let [ii, { abc: { kk } }] = [0, { abc: { kk: 1 } }];', + ['letett [ii, { abc: { kook } }] = [0, { abc: { kook: 1 } }];\r'] ], + [ 'ii', ['0\r', '0'] ], + [ 'kk', ['1\r', '1'] ], + [ 'var ll = await Promise.resolve(2);', + ['var letl = awaitaititt Proroomiseisesee.resolveolvelvevee(2);\r'] + ], + [ 'll', ['2\r', '2'] ], [ 'foo(await koo())', - [ 'f', '// 5oo', '// [Function: foo](await koo())\r', '4' ] ], - [ '_', ['// 4\r', '4'] ], + ['f', '5oo', '[Function: foo](awaitaititt kooo())\r', '4'] ], + [ '_', ['4\r', '4'] ], [ 'const m = foo(await koo());', - [ 'const m = foo(await koo());\r', 'undefined' ] ], - [ 'm', ['// 4\r', '4' ] ], + ['connst module = foo(awaitaititt kooo());\r'] ], + [ 'm', ['4\r', '4' ] ], [ 'const n = foo(await\nkoo());', - [ 'const n = foo(await\r', '... koo());\r', 'undefined' ] ], - [ 'n', ['// 4\r', '4' ] ], + ['connst n = foo(awaitaititt\r', '... kooo());\r', 'undefined'] ], + [ 'n', ['4\r', '4'] ], // eslint-disable-next-line no-template-curly-in-string [ '`status: ${(await Promise.resolve({ status: 200 })).status}`', - "'status: 200'"], + [ + '`stratus: ${(awaitaititt Proroomiseisesee.resolveolvelvevee' + + '({ stratus: 200 })).stratus}`\r', + "'status: 200'" + ] + ], [ 'for (let i = 0; i < 2; ++i) await i', - ['f', '// 5or (let i = 0; i < 2; ++i) await i\r', 'undefined'] ], + ['f', '5or (lett i = 0; i < 2; ++i) awaitaititt i\r', 'undefined'] ], [ 'for (let i = 0; i < 2; ++i) { await i }', - [ 'f', '// 5or (let i = 0; i < 2; ++i) { await i }\r', 'undefined' ] ], - [ 'await 0', ['await 0\r', '0'] ], + ['f', '5or (lett i = 0; i < 2; ++i) { awaitaititt i }\r', 'undefined'] + ], + [ 'await 0', ['awaitaititt 0\r', '0'] ], [ 'await 0; function foo() {}', - ['await 0; function foo() {}\r', 'undefined'] ], + ['awaitaititt 0; functionnctionctiontioniononn foo() {}\r'] + ], [ 'foo', - ['f', '// 5oo', '// [Function: foo]\r', '[Function: foo]'] ], - [ 'class Foo {}; await 1;', ['class Foo {}; await 1;\r', '1'] ], - [ 'Foo', ['// [Function: Foo]\r', '[Function: Foo]'] ], + ['f', '5oo', '[Function: foo]\r', '[Function: foo]'] ], + [ 'class Foo {}; await 1;', ['class Foo {}; awaitaititt 1;\r', '1'] ], + [ 'Foo', ['Fooo', '[Function: Foo]\r', '[Function: Foo]'] ], [ 'if (await true) { function bar() {}; }', - ['if (await true) { function bar() {}; }\r', 'undefined'] ], - [ 'bar', ['// [Function: bar]\r', '[Function: bar]'] ], - [ 'if (await true) { class Bar {}; }', 'undefined' ], + ['if (awaitaititt truee) { functionnctionctiontioniononn bar() {}; }\r'] + ], + [ 'bar', ['barr', '[Function: bar]\r', '[Function: bar]'] ], + [ 'if (await true) { class Bar {}; }', + ['if (awaitaititt truee) { class Bar {}; }\r'] + ], [ 'Bar', 'Uncaught ReferenceError: Bar is not defined' ], - [ 'await 0; function* gen(){}', 'undefined' ], + [ 'await 0; function* gen(){}', + ['awaitaititt 0; functionnctionctiontioniononn* globalen(){}\r'] + ], [ 'for (var i = 0; i < 10; ++i) { await i; }', - ['f', '// 5or (var i = 0; i < 10; ++i) { await i; }\r', 'undefined'] ], - [ 'i', ['// 10\r', '10'] ], + ['f', '5or (var i = 0; i < 10; ++i) { awaitaititt i; }\r', 'undefined'] ], + [ 'i', ['10\r', '10'] ], [ 'for (let j = 0; j < 5; ++j) { await j; }', - ['f', '// 5or (let j = 0; j < 5; ++j) { await j; }\r', 'undefined'] ], + ['f', '5or (lett j = 0; j < 5; ++j) { awaitaititt j; }\r', 'undefined'] ], [ 'j', 'Uncaught ReferenceError: j is not defined', { line: 0 } ], - [ 'gen', ['// [GeneratorFunction: gen]\r', '[GeneratorFunction: gen]'] ], + [ 'gen', + ['genn', '[GeneratorFunction: gen]\r', '[GeneratorFunction: gen]'] + ], [ 'return 42; await 5;', 'Uncaught SyntaxError: Illegal return statement', { line: 3 } ], - [ 'let o = await 1, p', 'undefined' ], - [ 'p', 'undefined' ], - [ 'let q = 1, s = await 2', 'undefined' ], - [ 's', ['// 2\r', '2'] ], + [ 'let o = await 1, p', ['lett os = awaitaititt 1, p\r'] ], + [ 'p', ['undefined\r'] ], + [ 'let q = 1, s = await 2', ['lett que = 1, s = awaitaititt 2\r'] ], + [ 's', ['2\r', '2'] ], [ 'for await (let i of [1,2,3]) console.log(i)', [ 'f', - '// 5or await (let i of [1,2,3]) console.log(i)\r', + '5or awaitaititt (lett i of [1,2,3]) connsolelee.logogg(i)\r', '1', '2', '3', @@ -160,6 +190,8 @@ async function ordinaryTests() { const toBeRun = input.split('\n'); const lines = await runAndWait(toBeRun); if (Array.isArray(expected)) { + if (expected.length === 1) + expected.push('undefined'); if (lines[0] === input) lines.shift(); assert.deepStrictEqual(lines, [...expected, PROMPT]); @@ -184,7 +216,7 @@ async function ctrlCTest() { 'await timeout(100000)', { ctrl: true, name: 'c' } ]), [ - 'await timeout(100000)\r', + 'awaitaititt timeoutmeouteoutoututt(100000)\r', 'Uncaught:', '[Error [ERR_SCRIPT_EXECUTION_INTERRUPTED]: ' + 'Script execution was interrupted by `SIGINT`] {',