From e997e86730cad2114a3f0ee50406f3921ac581b2 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 17 Dec 2019 18:00:13 +0100 Subject: [PATCH 1/8] repl: fix preview of lines that exceed the terminal columns This adds support for very long input lines to still display the input preview correct. --- lib/internal/repl/utils.js | 29 ++++---- test/parallel/test-repl-history-navigation.js | 71 +++++++++++++++---- test/parallel/test-repl-preview.js | 14 ++-- 3 files changed, 79 insertions(+), 35 deletions(-) diff --git a/lib/internal/repl/utils.js b/lib/internal/repl/utils.js index c54e173bdf3e1c..906046c40c5ede 100644 --- a/lib/internal/repl/utils.js +++ b/lib/internal/repl/utils.js @@ -132,11 +132,19 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) { let previewCompletionCounter = 0; let completionPreview = null; + function getPreviewPos() { + const displayPos = repl._getDisplayPos(`${repl._prompt}${repl.line}`); + const cursorPos = repl._getCursorPos(); + const rows = 1 + displayPos.rows - cursorPos.rows; + return { rows, cols: cursorPos.cols }; + } + const clearPreview = () => { if (inputPreview !== null) { - moveCursor(repl.output, 0, 1); + const { rows } = getPreviewPos(); + moveCursor(repl.output, 0, rows); clearLine(repl.output); - moveCursor(repl.output, 0, -1); + moveCursor(repl.output, 0, -rows); lastInputPreview = inputPreview; inputPreview = null; } @@ -280,16 +288,6 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) { return; } - // Do not show previews in case the current line is longer than the column - // width. - // TODO(BridgeAR): Fix me. This should not be necessary. It currently breaks - // the output though. We also have to check for characters that have more - // than a single byte as length. Check Interface.prototype._moveCursor. It - // contains the necessary logic. - if (repl.line.length + repl._prompt.length > repl.columns) { - 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 @@ -344,9 +342,12 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) { `\u001b[90m${inspected}\u001b[39m` : `// ${inspected}`; + const { rows: previewRows, cols: cursorCols } = getPreviewPos(); + if (previewRows !== 1) + moveCursor(repl.output, 0, previewRows - 1); + const { cols: resultCols } = repl._getDisplayPos(result); repl.output.write(`\n${result}`); - moveCursor(repl.output, 0, -1); - cursorTo(repl.output, repl._prompt.length + repl.cursor); + moveCursor(repl.output, cursorCols - resultCols, -previewRows); }); }; diff --git a/test/parallel/test-repl-history-navigation.js b/test/parallel/test-repl-history-navigation.js index f73fbb9b0fd278..766b3f0424711d 100644 --- a/test/parallel/test-repl-history-navigation.js +++ b/test/parallel/test-repl-history-navigation.js @@ -108,7 +108,7 @@ const tests = [ env: { NODE_REPL_HISTORY: defaultHistoryPath }, skip: !process.features.inspector, test: [ - `const ${'veryLongName'.repeat(30)} = 'I should not be previewed'`, + `const ${'veryLongName'.repeat(30)} = 'I should be previewed'`, ENTER, 'const e = new RangeError("visible\\ninvisible")', ENTER, @@ -127,27 +127,70 @@ const tests = [ { env: { NODE_REPL_HISTORY: defaultHistoryPath }, columns: 250, + showEscapeCodes: true, skip: !process.features.inspector, test: [ UP, UP, UP, + WORD_LEFT, UP, BACKSPACE ], + // A = Cursor n up + // B = Cursor n down + // C = Cursor n forward + // D = Cursor n back + // G = Cursor to column n + // J = Erase in screen; 0 = right; 1 = left; 2 = total + // K = Erase in line; 0 = right; 1 = left; 2 = total expected: [ - prompt, + // 0. Start + '\x1B[1G', '\x1B[0J', + prompt, '\x1B[3G', + // 1. UP // This exceeds the maximum columns (250): // Whitespace + prompt + ' // '.length + 'function'.length // 236 + 2 + 4 + 8 - `${prompt}${' '.repeat(236)} fun`, - `${prompt}${' '.repeat(235)} fun`, - ' // ction', - ' // ction', - `${prompt}${'veryLongName'.repeat(30)}`, - `${prompt}e`, - '\n// RangeError: visible', - prompt + '\x1B[1G', '\x1B[0J', + `${prompt}${' '.repeat(236)} fun`, '\x1B[243G', + // 2. UP + '\x1B[1G', '\x1B[0J', + `${prompt}${' '.repeat(235)} fun`, '\x1B[242G', + // TODO(BridgeAR): Investigate why the preview is generated twice. + ' // ction', '\x1B[242G', + ' // ction', '\x1B[242G', + // Preview cleanup + '\x1B[0K', + // 3. UP + '\x1B[1G', '\x1B[0J', + // 'veryLongName'.repeat(30).length === 360 + // prompt.length === 2 + // 360 % 250 + 2 === 112 (+1) + `${prompt}${'veryLongName'.repeat(30)}`, '\x1B[113G', + // "// 'I should be previewed'".length + 86 === 112 (+1) + "\n// 'I should be previewed'", '\x1B[86C\x1B[1A', + // Preview cleanup + '\x1B[1B', '\x1B[2K', '\x1B[1A', + // 4. WORD LEFT + // Almost identical as above. Just one extra line. + // Math.floor(360 / 250) === 1 + '\x1B[1A', + '\x1B[1G', '\x1B[0J', + `${prompt}${'veryLongName'.repeat(30)}`, '\x1B[3G', '\x1B[1A', + '\x1B[1B', "\n// 'I should be previewed'", '\x1B[24D\x1B[2A', + // Preview cleanup + '\x1B[2B', '\x1B[2K', '\x1B[2A', + // 5. UP + '\x1B[1G', '\x1B[0J', + `${prompt}e`, '\x1B[4G', + // '// RangeError: visible'.length - 19 === 3 (+1) + '\n// RangeError: visible', '\x1B[19D\x1B[1A', + // Preview cleanup + '\x1B[1B', '\x1B[2K', '\x1B[1A', + // 6. Backspace + '\x1B[1G', '\x1B[0J', + prompt, '\x1B[3G' ], clean: true }, @@ -169,11 +212,11 @@ const tests = [ WORD_RIGHT, ENTER ], - // C = Cursor forward - // D = Cursor back + // C = Cursor n forward + // D = Cursor n back // G = Cursor to column n - // J = Erase in screen - // K = Erase in line + // J = Erase in screen; 0 = right; 1 = left; 2 = total + // K = Erase in line; 0 = right; 1 = left; 2 = total expected: [ // 0. // 'f' diff --git a/test/parallel/test-repl-preview.js b/test/parallel/test-repl-preview.js index cd34c461d80671..4846248bdba294 100644 --- a/test/parallel/test-repl-preview.js +++ b/test/parallel/test-repl-preview.js @@ -68,12 +68,12 @@ async function tests(options) { const testCases = [ ['foo', [2, 4], '[Function: foo]', 'foo', - '\x1B[90m[Function: foo]\x1B[39m\x1B[1A\x1B[11G\x1B[1B\x1B[2K\x1B[1A\r', + '\x1B[90m[Function: foo]\x1B[39m\x1B[5D\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r', '\x1B[36m[Function: foo]\x1B[39m', '\x1B[1G\x1B[0Jrepl > \x1B[8G'], ['koo', [2, 4], '[Function: 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[90m[Function: koo]\x1B[39m\x1B[5D\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r', '\x1B[36m[Function: koo]\x1B[39m', '\x1B[1G\x1B[0Jrepl > \x1B[8G'], ['a', [1, 2], undefined], @@ -83,19 +83,19 @@ async function tests(options) { '\x1B[1G\x1B[0Jrepl > \x1B[8G'], ['1n + 2n', [2, 5], '\x1B[33m3n\x1B[39m', '1n + 2', - '\x1B[90mType[39m\x1B[1A\x1B[14G\x1B[1B\x1B[2K\x1B[1An', - '\x1B[90m3n\x1B[39m\x1B[1A\x1B[15G\x1B[1B\x1B[2K\x1B[1A\r', + '\x1B[90mType[39m\x1B[57D\x1B[1A\x1B[1B\x1B[2K\x1B[1An', + '\x1B[90m3n\x1B[39m\x1B[12C\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r', '\x1B[33m3n\x1B[39m', '\x1B[1G\x1B[0Jrepl > \x1B[8G'], ['{ a: true };', [2, 4], '\x1B[33mtrue\x1B[39m', '{ 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[90mtrue\x1B[39m\x1B[15C\x1B[1A\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: 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[90m{ a: true }\x1B[39m\x1B[8C\x1B[1A\x1B[1B\x1B[2K\x1B[1A;', + '\x1B[90mtrue\x1B[39m\x1B[16C\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r', '\x1B[33mtrue\x1B[39m', '\x1B[1G\x1B[0Jrepl > \x1B[8G'] ]; From be0fe19a11987725f338cfc3f3afac0700785880 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 16 Dec 2019 10:22:48 +0100 Subject: [PATCH 2/8] repl: implement reverse search Add a reverse search that works similar to the ZSH one. It is triggered with + r and + s. It skips duplicated history entries and works with multiline statements. Matching entries indicate the search parameter with an underscore and cancelling with + c or escape brings back the original line. Multiple matches in a single history entry work as well and are matched in the order of the current search direction. The cursor is positioned at the current match position of the history entry. Changing the direction immediately searches checks for the next entry in the expected direction from the current position on. Entries are accepted as soon any button is pressed that doesn't correspond with the reverse search. The behavior is deactivated for simple terminals. They do not support most ANSI escape codes that are necessary for this feature. --- lib/internal/repl/utils.js | 226 ++++++++++- lib/repl.js | 12 +- test/parallel/test-repl-history-navigation.js | 2 +- test/parallel/test-repl-reverse-search.js | 363 ++++++++++++++++++ test/pseudo-tty/repl-dumb-tty.js | 15 +- test/pseudo-tty/repl-dumb-tty.out | 1 + 6 files changed, 609 insertions(+), 10 deletions(-) create mode 100644 test/parallel/test-repl-reverse-search.js diff --git a/lib/internal/repl/utils.js b/lib/internal/repl/utils.js index 906046c40c5ede..ea4aa7f82a1aaa 100644 --- a/lib/internal/repl/utils.js +++ b/lib/internal/repl/utils.js @@ -24,6 +24,7 @@ const { const { clearLine, + clearScreenDown, cursorTo, moveCursor, } = require('readline'); @@ -42,7 +43,13 @@ const inspectOptions = { compact: true, breakLength: Infinity }; -const inspectedOptions = inspect(inspectOptions, { colors: false }); +// Specify options that might change the output in a way that it's not a valid +// stringified object anymore. +const inspectedOptions = inspect(inspectOptions, { + depth: 1, + colors: false, + showHidden: false +}); // If the error is that we've unexpectedly ended the input, // then let the user try to recover by adding more input. @@ -393,8 +400,223 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) { return { showPreview, clearPreview }; } +function setupReverseSearch(repl) { + // Simple terminals can't use reverse search. + if (process.env.TERM === 'dumb') { + return { reverseSearch() { return false; } }; + } + + const alreadyMatched = new Set(); + const labels = { + r: 'bck-i-search: ', + s: 'fwd-i-search: ' + }; + let isInReverseSearch = false; + let historyIndex = -1; + let input = ''; + let cursor = -1; + let dir = 'r'; + let lastMatch = -1; + let lastCursor = -1; + let promptPos; + + function next() { + return historyIndex >= 0 && historyIndex < repl.history.length; + } + + function isDirectionKey(keyName) { + if (!labels[keyName]) { + return false; + } + if (dir !== keyName) { + // Reset the already matched set in case the direction is changed. That + // way it's possible to find those entries again. + alreadyMatched.clear(); + } + dir = keyName; + return true; + } + + function goToNextHistoryIndex() { + // Ignore this entry for further searches and continue to the next + // history entry. + alreadyMatched.add(repl.history[historyIndex]); + historyIndex += dir === 'r' ? 1 : -1; + cursor = -1; + } + + function search() { + // Just print an empty line in case the user removed the search parameter. + if (input === '') { + print(repl.line, `${labels[dir]}_`); + return; + } + // Fix the bounds in case the direction has changed in the meanwhile. + if (dir === 'r') { + if (historyIndex < 0) { + historyIndex = 0; + } + } else if (historyIndex >= repl.history.length) { + historyIndex = repl.history.length - 1; + } + // Check the history entries until a match is found. + while (next()) { + let entry = repl.history[historyIndex]; + // Visualize all potential matches only once. + if (alreadyMatched.has(entry)) { + historyIndex += dir === 'r' ? 1 : -1; + continue; + } + // Match the next entry either from the start or from the end, depending + // on the current direction. + if (dir === 'r') { + // Update the cursor in case it's necessary. + if (cursor === -1) { + cursor = entry.length; + } + cursor = entry.lastIndexOf(input, cursor - 1); + } else { + cursor = entry.indexOf(input, cursor + 1); + } + // Match not found. + if (cursor === -1) { + goToNextHistoryIndex(); + // Match found. + } else { + if (repl.useColors) { + const start = entry.slice(0, cursor); + const end = entry.slice(cursor + input.length); + entry = `${start}\x1B[4m${input}\x1B[24m${end}`; + } + print(entry, `${labels[dir]}${input}_`, cursor); + lastMatch = historyIndex; + lastCursor = cursor; + // Explicitly go to the next history item in case no further matches are + // possible with the current entry. + if ((dir === 'r' && cursor === 0) || + (dir === 's' && entry.length === cursor + input.length)) { + goToNextHistoryIndex(); + } + return; + } + } + print(repl.line, `failed-${labels[dir]}${input}_`); + } + + function print(outputLine, inputLine, cursor = repl.cursor) { + let rows = 0; + if (lastMatch !== -1) { + const line = repl.history[lastMatch].slice(0, lastCursor); + rows = repl._getDisplayPos(`${repl._prompt}${line}`).rows; + cursorTo(repl.output, promptPos.cols); + } else if (isInReverseSearch && repl.line !== '') { + rows = repl._getCursorPos().rows; + cursorTo(repl.output, promptPos.cols); + } + if (rows !== 0) + moveCursor(repl.output, 0, -rows); + + if (isInReverseSearch) { + clearScreenDown(repl.output); + repl.output.write(`${outputLine}\n${inputLine}`); + } else { + repl.output.write(`\n${inputLine}`); + } + + lastMatch = -1; + + // To know exactly how many rows we have to move the cursor back we need the + // cursor rows, the output rows and the input rows. + const prompt = repl._prompt; + const cursorLine = `${prompt}${outputLine.slice(0, cursor)}`; + const cursorPos = repl._getDisplayPos(cursorLine); + const outputPos = repl._getDisplayPos(`${prompt}${outputLine}`); + const inputPos = repl._getDisplayPos(inputLine); + + const rowModifier = -1 - inputPos.rows - (outputPos.rows - cursorPos.rows); + + moveCursor(repl.output, 0, rowModifier); + cursorTo(repl.output, cursorPos.cols); + } + + function reset(string) { + isInReverseSearch = string !== undefined; + + // In case the reverse search ends and a history entry is found, reset the + // line to the found entry. + if (!isInReverseSearch) { + if (lastMatch !== -1) { + repl.line = repl.history[lastMatch]; + repl.cursor = lastCursor; + repl.historyIndex = lastMatch; + } + + lastMatch = -1; + + // Clear screen and write the current repl.line before exiting. + cursorTo(repl.output, promptPos.cols); + if (promptPos.rows !== 0) + moveCursor(repl.output, 0, promptPos.rows); + clearScreenDown(repl.output); + if (repl.line !== '') { + repl.output.write(repl.line); + if (repl.line.length !== repl.cursor) { + const { cols, rows } = repl._getCursorPos(); + cursorTo(repl.output, cols); + if (rows !== 0) + moveCursor(repl.output, 0, rows); + } + } + } + + input = string || ''; + cursor = -1; + historyIndex = repl.historyIndex; + alreadyMatched.clear(); + } + + function reverseSearch(string, key) { + if (!isInReverseSearch) { + if (key.ctrl && isDirectionKey(key.name)) { + historyIndex = repl.historyIndex; + promptPos = repl._getDisplayPos(`${repl._prompt}`); + print(repl.line, `${labels[dir]}_`); + isInReverseSearch = true; + } + } else if (key.ctrl && isDirectionKey(key.name)) { + search(); + } else if (key.name === 'backspace' || + (key.ctrl && (key.name === 'h' || key.name === 'w'))) { + reset(input.slice(0, input.length - 1)); + search(); + // Special handle + c and escape. Those should only cancel the + // reverse search. The original line is visible afterwards again. + } else if ((key.ctrl && key.name === 'c') || key.name === 'escape') { + lastMatch = -1; + reset(); + return true; + // End search in case either enter is pressed or if any non-reverse-search + // key (combination) is pressed. + } else if (key.ctrl || + key.meta || + key.name === 'return' || + key.name === 'enter' || + typeof string !== 'string' || + string === '') { + reset(); + } else { + reset(`${input}${string}`); + search(); + } + return isInReverseSearch; + } + + return { reverseSearch }; +} + module.exports = { isRecoverableError, kStandaloneREPL: Symbol('kStandaloneREPL'), - setupPreview + setupPreview, + setupReverseSearch }; diff --git a/lib/repl.js b/lib/repl.js index 24948477fea65e..6dd3ba0a22ead4 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -100,6 +100,7 @@ const { isRecoverableError, kStandaloneREPL, setupPreview, + setupReverseSearch, } = require('internal/repl/utils'); const { getOwnNonIndexProperties, @@ -808,6 +809,8 @@ function REPLServer(prompt, } }); + const { reverseSearch } = setupReverseSearch(this); + const { clearPreview, showPreview @@ -833,8 +836,10 @@ function REPLServer(prompt, self.clearLine(); } clearPreview(); - ttyWrite(d, key); - showPreview(); + if (!reverseSearch(d, key)) { + ttyWrite(d, key); + showPreview(); + } return; } @@ -1079,6 +1084,9 @@ REPLServer.prototype.complete = function() { this.completer.apply(this, arguments); }; +// TODO: Native module names should be auto-resolved. +// That improves the auto completion. + // Provide a list of completions for the given leading text. This is // given to the readline interface for handling tab completion. // diff --git a/test/parallel/test-repl-history-navigation.js b/test/parallel/test-repl-history-navigation.js index 766b3f0424711d..8de2b49b0ea5a6 100644 --- a/test/parallel/test-repl-history-navigation.js +++ b/test/parallel/test-repl-history-navigation.js @@ -340,7 +340,7 @@ function runTest() { const output = chunk.toString(); if (!opts.showEscapeCodes && - output.charCodeAt(0) === 27 || /^[\r\n]+$/.test(output)) { + (output[0] === '\x1B' || /^[\r\n]+$/.test(output))) { return next(); } diff --git a/test/parallel/test-repl-reverse-search.js b/test/parallel/test-repl-reverse-search.js new file mode 100644 index 00000000000000..a7c736300188de --- /dev/null +++ b/test/parallel/test-repl-reverse-search.js @@ -0,0 +1,363 @@ +'use strict'; + +// Flags: --expose-internals + +const common = require('../common'); +const stream = require('stream'); +const REPL = require('internal/repl'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { inspect } = require('util'); + +common.allowGlobals('aaaa'); + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +const defaultHistoryPath = path.join(tmpdir.path, '.node_repl_history'); + +// Create an input stream specialized for testing an array of actions +class ActionStream extends stream.Stream { + run(data) { + const _iter = data[Symbol.iterator](); + const doAction = () => { + const next = _iter.next(); + if (next.done) { + // Close the repl. Note that it must have a clean prompt to do so. + this.emit('keypress', '', { ctrl: true, name: 'd' }); + return; + } + const action = next.value; + + if (typeof action === 'object') { + this.emit('keypress', '', action); + } else { + this.emit('data', `${action}`); + } + setImmediate(doAction); + }; + doAction(); + } + resume() {} + pause() {} +} +ActionStream.prototype.readable = true; + +// Mock keys +const ENTER = { name: 'enter' }; +const UP = { name: 'up' }; +const DOWN = { name: 'down' }; +const BACKSPACE = { name: 'backspace' }; +const SEARCH_BACKWARDS = { name: 'r', ctrl: true }; +const SEARCH_FORWARDS = { name: 's', ctrl: true }; +const ESCAPE = { name: 'escape' }; +const CTRL_C = { name: 'c', ctrl: true }; +const DELETE_WORD_LEFT = { name: 'w', ctrl: true }; + +const prompt = '> '; + +// TODO(BridgeAR): Add tests for lines that exceed the maximum columns. +const tests = [ + { // Creates few history to navigate for + env: { NODE_REPL_HISTORY: defaultHistoryPath }, + test: [ + 'console.log("foo")', ENTER, + 'ab = "aaaa"', ENTER, + 'repl.repl.historyIndex', ENTER, + 'console.log("foo")', ENTER, + 'let ba = 9', ENTER, + 'ab = "aaaa"', ENTER, + '555 - 909', ENTER, + '{key : {key2 :[] }}', ENTER, + 'Array(100).fill(1)', ENTER + ], + expected: [], + clean: false + }, + { + env: { NODE_REPL_HISTORY: defaultHistoryPath }, + showEscapeCodes: true, + checkTotal: true, + useColors: true, + test: [ + '7', // 1 + SEARCH_FORWARDS, + SEARCH_FORWARDS, // 3 + 'a', + SEARCH_BACKWARDS, // 5 + SEARCH_FORWARDS, + SEARCH_BACKWARDS, // 7 + 'a', + BACKSPACE, // 9 + DELETE_WORD_LEFT, + 'aa', // 11 + SEARCH_BACKWARDS, + SEARCH_BACKWARDS, // 13 + SEARCH_BACKWARDS, + SEARCH_BACKWARDS, // 15 + SEARCH_FORWARDS, + ESCAPE, // 17 + ENTER + ], + // A = Cursor n up + // B = Cursor n down + // C = Cursor n forward + // D = Cursor n back + // G = Cursor to column n + // J = Erase in screen; 0 = right; 1 = left; 2 = total + // K = Erase in line; 0 = right; 1 = left; 2 = total + expected: [ + // 0. Start + '\x1B[1G', '\x1B[0J', + prompt, '\x1B[3G', + // 1. '7' + '7', + // 2. SEARCH FORWARDS + '\nfwd-i-search: _', '\x1B[1A', '\x1B[4G', + // 3. SEARCH FORWARDS + '\x1B[3G', '\x1B[0J', + '7\nfwd-i-search: _', '\x1B[1A', '\x1B[4G', + // 4. 'a' + '\x1B[3G', '\x1B[0J', + '7\nfailed-fwd-i-search: a_', '\x1B[1A', '\x1B[4G', + // 5. SEARCH BACKWARDS + '\x1B[3G', '\x1B[0J', + 'Arr\x1B[4ma\x1B[24my(100).fill(1)\nbck-i-search: a_', + '\x1B[1A', '\x1B[6G', + // 6. SEARCH FORWARDS + '\x1B[3G', '\x1B[0J', + '7\nfailed-fwd-i-search: a_', '\x1B[1A', '\x1B[4G', + // 7. SEARCH BACKWARDS + '\x1B[3G', '\x1B[0J', + 'Arr\x1B[4ma\x1B[24my(100).fill(1)\nbck-i-search: a_', + '\x1B[1A', '\x1B[6G', + // 8. 'a' + '\x1B[3G', '\x1B[0J', + 'ab = "aa\x1B[4maa\x1B[24m"\nbck-i-search: aa_', + '\x1B[1A', '\x1B[11G', + // 9. BACKSPACE + '\x1B[3G', '\x1B[0J', + 'Arr\x1B[4ma\x1B[24my(100).fill(1)\nbck-i-search: a_', + '\x1B[1A', '\x1B[6G', + // 10. DELETE WORD LEFT (works as backspace) + '\x1B[3G', '\x1B[0J', + '7\nbck-i-search: _', '\x1B[1A', '\x1B[4G', + // 11. 'a' + '\x1B[3G', '\x1B[0J', + 'Arr\x1B[4ma\x1B[24my(100).fill(1)\nbck-i-search: a_', + '\x1B[1A', '\x1B[6G', + // 11. 'aa' - continued + '\x1B[3G', '\x1B[0J', + 'ab = "aa\x1B[4maa\x1B[24m"\nbck-i-search: aa_', + '\x1B[1A', '\x1B[11G', + // 12. SEARCH BACKWARDS + '\x1B[3G', '\x1B[0J', + 'ab = "a\x1B[4maa\x1B[24ma"\nbck-i-search: aa_', + '\x1B[1A', '\x1B[10G', + // 13. SEARCH BACKWARDS + '\x1B[3G', '\x1B[0J', + 'ab = "\x1B[4maa\x1B[24maa"\nbck-i-search: aa_', + '\x1B[1A', '\x1B[9G', + // 14. SEARCH BACKWARDS + '\x1B[3G', '\x1B[0J', + '7\nfailed-bck-i-search: aa_', '\x1B[1A', '\x1B[4G', + // 15. SEARCH BACKWARDS + '\x1B[3G', '\x1B[0J', + '7\nfailed-bck-i-search: aa_', '\x1B[1A', '\x1B[4G', + // 16. SEARCH FORWARDS + '\x1B[3G', '\x1B[0J', + 'ab = "\x1B[4maa\x1B[24maa"\nfwd-i-search: aa_', + '\x1B[1A', '\x1B[9G', + // 17. ESCAPE + '\x1B[3G', '\x1B[0J', + '7', + // 18. ENTER + '\r\n', + '\x1B[33m7\x1B[39m\n', + '\x1B[1G', '\x1B[0J', + prompt, + '\x1B[3G', + '\r\n' + ], + clean: false + }, + { + env: { NODE_REPL_HISTORY: defaultHistoryPath }, + showEscapeCodes: true, + skip: !process.features.inspector, + checkTotal: true, + useColors: false, + test: [ + 'fu', // 1 + SEARCH_BACKWARDS, + '}', // 3 + SEARCH_BACKWARDS, + CTRL_C, // 5 + CTRL_C, + '1+1', // 7 + ENTER, + SEARCH_BACKWARDS, // 9 + '+', + '\r', // 11 + '2', + SEARCH_BACKWARDS, // 13 + 're', + UP, // 15 + DOWN, + SEARCH_FORWARDS, // 17 + '\n' + ], + expected: [ + '\x1B[1G', '\x1B[0J', + prompt, '\x1B[3G', + 'f', 'u', ' // nction', + '\x1B[5G', '\x1B[0K', + '\nbck-i-search: _', '\x1B[1A', '\x1B[5G', + '\x1B[3G', '\x1B[0J', + '{key : {key2 :[] }}\nbck-i-search: }_', '\x1B[1A', '\x1B[21G', + '\x1B[3G', '\x1B[0J', + '{key : {key2 :[] }}\nbck-i-search: }_', '\x1B[1A', '\x1B[20G', + '\x1B[3G', '\x1B[0J', + 'fu', + '\r\n', + '\x1B[1G', '\x1B[0J', + prompt, '\x1B[3G', + '1', '+', '1', '\n// 2', '\x1B[1C\x1B[1A', + '\x1B[1B', '\x1B[2K', '\x1B[1A', + '\r\n', + '2\n', + '\x1B[1G', '\x1B[0J', + prompt, '\x1B[3G', + '\nbck-i-search: _', '\x1B[1A', + '\x1B[3G', '\x1B[0J', + '1+1\nbck-i-search: +_', '\x1B[1A', '\x1B[4G', + '\x1B[3G', '\x1B[0J', + '1+1', '\x1B[4G', + '\x1B[2C', + '\r\n', + '2\n', + '\x1B[1G', '\x1B[0J', + prompt, '\x1B[3G', + '2', '\n// 2', '\x1B[1D\x1B[1A', + '\x1B[1B', '\x1B[2K', '\x1B[1A', + '\nbck-i-search: _', '\x1B[1A', '\x1B[4G', + '\x1B[3G', '\x1B[0J', + 'Array(100).fill(1)\nbck-i-search: r_', '\x1B[1A', '\x1B[5G', + '\x1B[3G', '\x1B[0J', + 'repl.repl.historyIndex\nbck-i-search: re_', '\x1B[1A', '\x1B[8G', + '\x1B[3G', '\x1B[0J', + 'repl.repl.historyIndex', '\x1B[8G', + '\x1B[1G', '\x1B[0J', + `${prompt}ab = "aaaa"`, '\x1B[14G', + '\x1B[1G', '\x1B[0J', + `${prompt}repl.repl.historyIndex`, '\x1B[25G', '\n// -1', + '\x1B[19C\x1B[1A', + '\x1B[1B', '\x1B[2K', '\x1B[1A', + '\nfwd-i-search: _', '\x1B[1A', '\x1B[25G', + '\x1B[3G', '\x1B[0J', + 'repl.repl.historyIndex', + '\r\n', + '-1\n', + '\x1B[1G', '\x1B[0J', + prompt, '\x1B[3G', + '\r\n' + ], + clean: false + } +]; +const numtests = tests.length; + +const runTestWrap = common.mustCall(runTest, numtests); + +function cleanupTmpFile() { + try { + // Write over the file, clearing any history + fs.writeFileSync(defaultHistoryPath, ''); + } catch (err) { + if (err.code === 'ENOENT') return true; + throw err; + } + return true; +} + +function runTest() { + const opts = tests.shift(); + if (!opts) return; // All done + + const { expected, skip } = opts; + + // Test unsupported on platform. + if (skip) { + setImmediate(runTestWrap, true); + return; + } + + const lastChunks = []; + let i = 0; + + REPL.createInternalRepl(opts.env, { + input: new ActionStream(), + output: new stream.Writable({ + write(chunk, _, next) { + const output = chunk.toString(); + + if (!opts.showEscapeCodes && + (output[0] === '\x1B' || /^[\r\n]+$/.test(output))) { + return next(); + } + + lastChunks.push(output); + + if (expected.length) { + try { + if (!opts.checkTotal) + assert.strictEqual(output, expected[i]); + } catch (e) { + console.error(`Failed test # ${numtests - tests.length}`); + console.error('Last outputs: ' + inspect(lastChunks, { + breakLength: 5, colors: true + })); + throw e; + } + i++; + } + + next(); + } + }), + completer: opts.completer, + prompt, + useColors: opts.useColors || false, + terminal: true + }, function(err, repl) { + if (err) { + console.error(`Failed test # ${numtests - tests.length}`); + throw err; + } + + repl.once('close', () => { + if (opts.clean) + cleanupTmpFile(); + + if (opts.checkTotal) { + assert.deepStrictEqual(lastChunks, expected); + } else if (expected.length !== 0) { + throw new Error(`Failed test # ${numtests - tests.length}`); + } + + setImmediate(runTestWrap, true); + }); + + if (opts.columns) { + Object.defineProperty(repl, 'columns', { + value: opts.columns, + enumerable: true + }); + } + repl.inputStream.run(opts.test); + }); +} + +// run the tests +runTest(); diff --git a/test/pseudo-tty/repl-dumb-tty.js b/test/pseudo-tty/repl-dumb-tty.js index 1a3a24299821fe..8c9b93a9f31ccd 100644 --- a/test/pseudo-tty/repl-dumb-tty.js +++ b/test/pseudo-tty/repl-dumb-tty.js @@ -7,25 +7,30 @@ const repl = require('repl'); const ArrayStream = require('../common/arraystream'); repl.start('> '); -process.stdin.push('console.log("foo")\n'); -process.stdin.push('1 + 2\n'); +process.stdin.push('conso'); // No completion preview. +process.stdin.push('le.log("foo")\n'); +process.stdin.push('1 + 2'); // No input preview. +process.stdin.push('\n'); process.stdin.push('"str"\n'); process.stdin.push('console.dir({ a: 1 })\n'); process.stdin.push('{ a: 1 }\n'); process.stdin.push('\n'); process.stdin.push('.exit\n'); -// Verify Control+D support. +// Verify + D support. { const stream = new ArrayStream(); - const replServer = repl.start({ + const replServer = new repl.REPLServer({ prompt: '> ', terminal: true, input: stream, - output: stream, + output: process.stdout, useColors: false }); replServer.on('close', common.mustCall()); + // Verify that + R or + C does not trigger the reverse search. + replServer.write(null, { ctrl: true, name: 'r' }); + replServer.write(null, { ctrl: true, name: 's' }); replServer.write(null, { ctrl: true, name: 'd' }); } diff --git a/test/pseudo-tty/repl-dumb-tty.out b/test/pseudo-tty/repl-dumb-tty.out index 69eb4e5da6313e..3304faff0a4f4f 100644 --- a/test/pseudo-tty/repl-dumb-tty.out +++ b/test/pseudo-tty/repl-dumb-tty.out @@ -12,3 +12,4 @@ undefined { a: 1 } > > .exit +> From 4a41ffc3edd4b2f6ab2670a73425523769834548 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 17 Dec 2019 18:01:09 +0100 Subject: [PATCH 3/8] readline: small refactoring This just removes some redundant work and some other small things. --- lib/readline.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/readline.js b/lib/readline.js index daf30969408222..7c8488122d5bb4 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -719,7 +719,7 @@ Interface.prototype._historyPrev = function() { Interface.prototype._getDisplayPos = function(str) { let offset = 0; const col = this.columns; - let row = 0; + let rows = 0; str = stripVTControlCharacters(str); for (let i = 0, len = str.length; i < len; i++) { const code = str.codePointAt(i); @@ -727,8 +727,8 @@ Interface.prototype._getDisplayPos = function(str) { i++; } if (code === 0x0a) { // new line \n - // row must be incremented by 1 even if offset = 0 or col = +Infinity - row += MathCeil(offset / col) || 1; + // rows must be incremented by 1 even if offset = 0 or col = +Infinity + rows += MathCeil(offset / col) || 1; offset = 0; continue; } @@ -743,8 +743,8 @@ Interface.prototype._getDisplayPos = function(str) { } } const cols = offset % col; - const rows = row + (offset - cols) / col; - return { cols: cols, rows: rows }; + rows += (offset - cols) / col; + return { cols, rows }; }; @@ -752,8 +752,7 @@ Interface.prototype._getDisplayPos = function(str) { Interface.prototype._getCursorPos = function() { const columns = this.columns; const strBeforeCursor = this._prompt + this.line.substring(0, this.cursor); - const dispPos = this._getDisplayPos( - stripVTControlCharacters(strBeforeCursor)); + 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, @@ -764,7 +763,7 @@ Interface.prototype._getCursorPos = function() { rows++; cols = 0; } - return { cols: cols, rows: rows }; + return { cols, rows }; }; From 7f38f0a422dbb6ab6632fd664318dd435a35eafb Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Wed, 18 Dec 2019 19:58:49 +0100 Subject: [PATCH 4/8] readline: set null as callback return in case there's no error The cursor move functions accept a callback. It was possible that `undefined` was returned in case there was no error instead of null. --- lib/readline.js | 8 ++++---- test/parallel/test-readline-csi.js | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/readline.js b/lib/readline.js index 7c8488122d5bb4..3129f1617604f4 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -1198,7 +1198,7 @@ function cursorTo(stream, x, y, callback) { if (stream == null || (typeof x !== 'number' && typeof y !== 'number')) { if (typeof callback === 'function') - process.nextTick(callback); + process.nextTick(callback, null); return true; } @@ -1219,7 +1219,7 @@ function moveCursor(stream, dx, dy, callback) { if (stream == null || !(dx || dy)) { if (typeof callback === 'function') - process.nextTick(callback); + process.nextTick(callback, null); return true; } @@ -1253,7 +1253,7 @@ function clearLine(stream, dir, callback) { if (stream === null || stream === undefined) { if (typeof callback === 'function') - process.nextTick(callback); + process.nextTick(callback, null); return true; } @@ -1275,7 +1275,7 @@ function clearScreenDown(stream, callback) { if (stream === null || stream === undefined) { if (typeof callback === 'function') - process.nextTick(callback); + process.nextTick(callback, null); return true; } diff --git a/test/parallel/test-readline-csi.js b/test/parallel/test-readline-csi.js index 53b07d7bd939fd..346f1cbad12db1 100644 --- a/test/parallel/test-readline-csi.js +++ b/test/parallel/test-readline-csi.js @@ -39,7 +39,9 @@ assert.throws(() => { }, /ERR_INVALID_CALLBACK/); // Verify that clearScreenDown() does not throw on null or undefined stream. -assert.strictEqual(readline.clearScreenDown(null, common.mustCall()), true); +assert.strictEqual(readline.clearScreenDown(null, common.mustCall((err) => { + assert.strictEqual(err, null); +})), true); assert.strictEqual(readline.clearScreenDown(undefined, common.mustCall()), true); @@ -67,7 +69,9 @@ assert.throws(() => { // Verify that clearLine() does not throw on null or undefined stream. assert.strictEqual(readline.clearLine(null, 0), true); assert.strictEqual(readline.clearLine(undefined, 0), true); -assert.strictEqual(readline.clearLine(null, 0, common.mustCall()), true); +assert.strictEqual(readline.clearLine(null, 0, common.mustCall((err) => { + assert.strictEqual(err, null); +})), true); assert.strictEqual(readline.clearLine(undefined, 0, common.mustCall()), true); // Nothing is written when moveCursor 0, 0 @@ -101,7 +105,9 @@ assert.throws(() => { // Verify that moveCursor() does not throw on null or undefined stream. assert.strictEqual(readline.moveCursor(null, 1, 1), true); assert.strictEqual(readline.moveCursor(undefined, 1, 1), true); -assert.strictEqual(readline.moveCursor(null, 1, 1, common.mustCall()), true); +assert.strictEqual(readline.moveCursor(null, 1, 1, common.mustCall((err) => { + assert.strictEqual(err, null); +})), true); assert.strictEqual(readline.moveCursor(undefined, 1, 1, common.mustCall()), true); @@ -109,7 +115,9 @@ assert.strictEqual(readline.moveCursor(undefined, 1, 1, common.mustCall()), assert.strictEqual(readline.cursorTo(null), true); assert.strictEqual(readline.cursorTo(), true); assert.strictEqual(readline.cursorTo(null, 1, 1, common.mustCall()), true); -assert.strictEqual(readline.cursorTo(undefined, 1, 1, common.mustCall()), true); +assert.strictEqual(readline.cursorTo(undefined, 1, 1, common.mustCall((err) => { + assert.strictEqual(err, null); +})), true); writable.data = ''; assert.strictEqual(readline.cursorTo(writable, 'a'), true); From 415d5a16ee87eed6f7ca13ecca2f8afad7964904 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Thu, 19 Dec 2019 13:27:10 +0100 Subject: [PATCH 5/8] fixup: repl - fix very long search line --- lib/internal/repl/utils.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/internal/repl/utils.js b/lib/internal/repl/utils.js index ea4aa7f82a1aaa..0e560c9ebaab4d 100644 --- a/lib/internal/repl/utils.js +++ b/lib/internal/repl/utils.js @@ -532,10 +532,11 @@ function setupReverseSearch(repl) { const cursorPos = repl._getDisplayPos(cursorLine); const outputPos = repl._getDisplayPos(`${prompt}${outputLine}`); const inputPos = repl._getDisplayPos(inputLine); + const inputRows = inputPos.rows - (inputPos.cols === 0 ? 1 : 0); - const rowModifier = -1 - inputPos.rows - (outputPos.rows - cursorPos.rows); + rows = -1 - inputRows - (outputPos.rows - cursorPos.rows); - moveCursor(repl.output, 0, rowModifier); + moveCursor(repl.output, 0, rows); cursorTo(repl.output, cursorPos.cols); } From 19ded6706a874b9a707d55543f073a679045a41b Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Thu, 19 Dec 2019 16:03:11 +0100 Subject: [PATCH 6/8] fixup: add big todo comment --- lib/internal/repl/utils.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/internal/repl/utils.js b/lib/internal/repl/utils.js index 0e560c9ebaab4d..892b35ff54b85d 100644 --- a/lib/internal/repl/utils.js +++ b/lib/internal/repl/utils.js @@ -504,6 +504,27 @@ function setupReverseSearch(repl) { } function print(outputLine, inputLine, cursor = repl.cursor) { + // TODO(BridgeAR): Resizing the terminal window breaks this. To fix that, + // readline must be aware of this information. It's probably best to add a + // couple of properties to readline that allow to do the following: + // 1. Add arbitrary data to the end of the current line while not counting + // towards the line. This would be useful for the completion previews. + // 2. Add arbitrary extra lines that do not count towards the regular line. + // This would be useful for both, the input preview and the reverse + // search. It might be combined with the first part? + // 3. Add arbitrary input that is "on top" of the current line. That is + // useful for the reverse search. + // 4. To trigger the line refresh, functions should be used to pass through + // the information. + // The data would then be accounted for when calling `_refreshLine()`. + // This function would then look similar to: + // repl.overlay(outputLine); + // repl.addTrailingLine(inputLine); + // repl.setCursor(cursor); + // More potential improvements: use something similar to stream.cork(). + // Multiple cursor moves on the same tick could be prevented in case all + // writes from the same tick are combined and the cursor is moved at the + // tick end instead of after each operation. let rows = 0; if (lastMatch !== -1) { const line = repl.history[lastMatch].slice(0, lastCursor); From 29fcab97828bd9b97701006c41ea8b5014fadcb7 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Fri, 20 Dec 2019 15:24:01 +0100 Subject: [PATCH 7/8] fixup: address comments --- lib/internal/repl/utils.js | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/internal/repl/utils.js b/lib/internal/repl/utils.js index 892b35ff54b85d..e6383e90e1046e 100644 --- a/lib/internal/repl/utils.js +++ b/lib/internal/repl/utils.js @@ -420,11 +420,7 @@ function setupReverseSearch(repl) { let lastCursor = -1; let promptPos; - function next() { - return historyIndex >= 0 && historyIndex < repl.history.length; - } - - function isDirectionKey(keyName) { + function checkAndSetDirectionKey(keyName) { if (!labels[keyName]) { return false; } @@ -460,7 +456,7 @@ function setupReverseSearch(repl) { historyIndex = repl.history.length - 1; } // Check the history entries until a match is found. - while (next()) { + while (historyIndex >= 0 && historyIndex < repl.history.length) { let entry = repl.history[historyIndex]; // Visualize all potential matches only once. if (alreadyMatched.has(entry)) { @@ -504,9 +500,9 @@ function setupReverseSearch(repl) { } function print(outputLine, inputLine, cursor = repl.cursor) { - // TODO(BridgeAR): Resizing the terminal window breaks this. To fix that, - // readline must be aware of this information. It's probably best to add a - // couple of properties to readline that allow to do the following: + // TODO(BridgeAR): Resizing the terminal window hides the overlay. To fix + // that, readline must be aware of this information. It's probably best to + // add a couple of properties to readline that allow to do the following: // 1. Add arbitrary data to the end of the current line while not counting // towards the line. This would be useful for the completion previews. // 2. Add arbitrary extra lines that do not count towards the regular line. @@ -515,7 +511,8 @@ function setupReverseSearch(repl) { // 3. Add arbitrary input that is "on top" of the current line. That is // useful for the reverse search. // 4. To trigger the line refresh, functions should be used to pass through - // the information. + // the information. Alternatively, getters and setters could be used. + // That might even be more elegant. // The data would then be accounted for when calling `_refreshLine()`. // This function would then look similar to: // repl.overlay(outputLine); @@ -599,13 +596,13 @@ function setupReverseSearch(repl) { function reverseSearch(string, key) { if (!isInReverseSearch) { - if (key.ctrl && isDirectionKey(key.name)) { + if (key.ctrl && checkAndSetDirectionKey(key.name)) { historyIndex = repl.historyIndex; promptPos = repl._getDisplayPos(`${repl._prompt}`); print(repl.line, `${labels[dir]}_`); isInReverseSearch = true; } - } else if (key.ctrl && isDirectionKey(key.name)) { + } else if (key.ctrl && checkAndSetDirectionKey(key.name)) { search(); } else if (key.name === 'backspace' || (key.ctrl && (key.name === 'h' || key.name === 'w'))) { From f3387ada3e992da6411df998f7399fee175f94b6 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Sat, 21 Dec 2019 13:34:23 +0100 Subject: [PATCH 8/8] fixup: document the reverse-i-search --- doc/api/repl.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/doc/api/repl.md b/doc/api/repl.md index c545be16cb8a9c..51a9d30bfc7b02 100644 --- a/doc/api/repl.md +++ b/doc/api/repl.md @@ -21,9 +21,11 @@ result. Input and output may be from `stdin` and `stdout`, respectively, or may be connected to any Node.js [stream][]. Instances of [`repl.REPLServer`][] support automatic completion of inputs, -simplistic Emacs-style line editing, multi-line inputs, ANSI-styled output, -saving and restoring current REPL session state, error recovery, and -customizable evaluation functions. +completion preview, simplistic Emacs-style line editing, multi-line inputs, +[ZSH][] like reverse-i-search, ANSI-styled output, saving and restoring current +REPL session state, error recovery, and customizable evaluation functions. +Terminals that do not support ANSI-styles and Emacs-style line editing +automatically fall back to a limited feature set. ### Commands and Special Keys @@ -232,6 +234,24 @@ undefined undefined ``` +### Reverse-i-search + + +The REPL supports bi-directional reverse-i-search similar to [ZSH][]. It is +triggered with ` + R` to search backwards and ` + S` to search +forwards. + +Duplicated history entires will be skipped. + +Entries are accepted as soon as any button is pressed that doesn't correspond +with the reverse search. Cancelling is possible by pressing `escape` or +` + C`. + +Changing the direction immediately searches for the next entry in the expected +direction from the current position on. + ### Custom Evaluation Functions When a new [`repl.REPLServer`][] is created, a custom evaluation function may be @@ -695,6 +715,7 @@ a `net.Server` and `net.Socket` instance, see: For an example of running a REPL instance over [curl(1)][], see: . +[ZSH]: https://en.wikipedia.org/wiki/Z_shell [`'uncaughtException'`]: process.html#process_event_uncaughtexception [`--experimental-repl-await`]: cli.html#cli_experimental_repl_await [`ERR_DOMAIN_CANNOT_SET_UNCAUGHT_EXCEPTION_CAPTURE`]: errors.html#errors_err_domain_cannot_set_uncaught_exception_capture