1'use strict'; 2 3const { 4 MathMin, 5 Set, 6 Symbol, 7} = primordials; 8 9const { tokTypes: tt, Parser: AcornParser } = 10 require('internal/deps/acorn/acorn/dist/acorn'); 11const privateMethods = 12 require('internal/deps/acorn-plugins/acorn-private-methods/index'); 13const classFields = 14 require('internal/deps/acorn-plugins/acorn-class-fields/index'); 15const numericSeparator = 16 require('internal/deps/acorn-plugins/acorn-numeric-separator/index'); 17const staticClassFeatures = 18 require('internal/deps/acorn-plugins/acorn-static-class-features/index'); 19 20const { sendInspectorCommand } = require('internal/util/inspector'); 21 22const { 23 ERR_INSPECTOR_NOT_AVAILABLE 24} = require('internal/errors').codes; 25 26const { 27 clearLine, 28 clearScreenDown, 29 cursorTo, 30 moveCursor, 31} = require('readline'); 32 33const { 34 commonPrefix, 35 kSubstringSearch, 36} = require('internal/readline/utils'); 37 38const { 39 getStringWidth, 40 inspect, 41} = require('internal/util/inspect'); 42 43let debug = require('internal/util/debuglog').debuglog('repl', (fn) => { 44 debug = fn; 45}); 46 47const previewOptions = { 48 colors: false, 49 depth: 1, 50 showHidden: false 51}; 52 53// If the error is that we've unexpectedly ended the input, 54// then let the user try to recover by adding more input. 55// Note: `e` (the original exception) is not used by the current implementation, 56// but may be needed in the future. 57function isRecoverableError(e, code) { 58 // For similar reasons as `defaultEval`, wrap expressions starting with a 59 // curly brace with parenthesis. Note: only the open parenthesis is added 60 // here as the point is to test for potentially valid but incomplete 61 // expressions. 62 if (/^\s*\{/.test(code) && isRecoverableError(e, `(${code}`)) return true; 63 64 let recoverable = false; 65 66 // Determine if the point of any error raised is at the end of the input. 67 // There are two cases to consider: 68 // 69 // 1. Any error raised after we have encountered the 'eof' token. 70 // This prevents us from declaring partial tokens (like '2e') as 71 // recoverable. 72 // 73 // 2. Three cases where tokens can legally span lines. This is 74 // template, comment, and strings with a backslash at the end of 75 // the line, indicating a continuation. Note that we need to look 76 // for the specific errors of 'unterminated' kind (not, for example, 77 // a syntax error in a ${} expression in a template), and the only 78 // way to do that currently is to look at the message. Should Acorn 79 // change these messages in the future, this will lead to a test 80 // failure, indicating that this code needs to be updated. 81 // 82 const RecoverableParser = AcornParser 83 .extend( 84 privateMethods, 85 classFields, 86 numericSeparator, 87 staticClassFeatures, 88 (Parser) => { 89 return class extends Parser { 90 nextToken() { 91 super.nextToken(); 92 if (this.type === tt.eof) 93 recoverable = true; 94 } 95 raise(pos, message) { 96 switch (message) { 97 case 'Unterminated template': 98 case 'Unterminated comment': 99 recoverable = true; 100 break; 101 102 case 'Unterminated string constant': 103 const token = this.input.slice(this.lastTokStart, this.pos); 104 // See https://www.ecma-international.org/ecma-262/#sec-line-terminators 105 if (/\\(?:\r\n?|\n|\u2028|\u2029)$/.test(token)) { 106 recoverable = true; 107 } 108 } 109 super.raise(pos, message); 110 } 111 }; 112 } 113 ); 114 115 // Try to parse the code with acorn. If the parse fails, ignore the acorn 116 // error and return the recoverable status. 117 try { 118 RecoverableParser.parse(code, { ecmaVersion: 11 }); 119 120 // Odd case: the underlying JS engine (V8, Chakra) rejected this input 121 // but Acorn detected no issue. Presume that additional text won't 122 // address this issue. 123 return false; 124 } catch { 125 return recoverable; 126 } 127} 128 129function setupPreview(repl, contextSymbol, bufferSymbol, active) { 130 // Simple terminals can't handle previews. 131 if (process.env.TERM === 'dumb' || !active) { 132 return { showPreview() {}, clearPreview() {} }; 133 } 134 135 let inputPreview = null; 136 let lastInputPreview = ''; 137 138 let previewCompletionCounter = 0; 139 let completionPreview = null; 140 141 let wrapped = false; 142 143 function getPreviewPos() { 144 const displayPos = repl._getDisplayPos(`${repl._prompt}${repl.line}`); 145 const cursorPos = repl.line.length !== repl.cursor ? 146 repl.getCursorPos() : 147 displayPos; 148 return { displayPos, cursorPos }; 149 } 150 151 const clearPreview = () => { 152 if (inputPreview !== null) { 153 const { displayPos, cursorPos } = getPreviewPos(); 154 const rows = displayPos.rows - cursorPos.rows + 1; 155 moveCursor(repl.output, 0, rows); 156 clearLine(repl.output); 157 moveCursor(repl.output, 0, -rows); 158 lastInputPreview = inputPreview; 159 inputPreview = null; 160 } 161 if (completionPreview !== null) { 162 // Prevent cursor moves if not necessary! 163 const move = repl.line.length !== repl.cursor; 164 let pos, rows; 165 if (move) { 166 pos = getPreviewPos(); 167 cursorTo(repl.output, pos.displayPos.cols); 168 rows = pos.displayPos.rows - pos.cursorPos.rows; 169 moveCursor(repl.output, 0, rows); 170 } 171 const totalLine = `${repl._prompt}${repl.line}${completionPreview}`; 172 const newPos = repl._getDisplayPos(totalLine); 173 // Minimize work for the terminal. It is enough to clear the right part of 174 // the current line in case the preview is visible on a single line. 175 if (newPos.rows === 0 || (pos && pos.displayPos.rows === newPos.rows)) { 176 clearLine(repl.output, 1); 177 } else { 178 clearScreenDown(repl.output); 179 } 180 if (move) { 181 cursorTo(repl.output, pos.cursorPos.cols); 182 moveCursor(repl.output, 0, -rows); 183 } 184 completionPreview = null; 185 } 186 }; 187 188 function showCompletionPreview(line, insertPreview) { 189 previewCompletionCounter++; 190 191 const count = previewCompletionCounter; 192 193 repl.completer(line, (error, data) => { 194 // Tab completion might be async and the result might already be outdated. 195 if (count !== previewCompletionCounter) { 196 return; 197 } 198 199 if (error) { 200 debug('Error while generating completion preview', error); 201 return; 202 } 203 204 // Result and the text that was completed. 205 const [rawCompletions, completeOn] = data; 206 207 if (!rawCompletions || rawCompletions.length === 0) { 208 return; 209 } 210 211 // If there is a common prefix to all matches, then apply that portion. 212 const completions = rawCompletions.filter((e) => e); 213 const prefix = commonPrefix(completions); 214 215 // No common prefix found. 216 if (prefix.length <= completeOn.length) { 217 return; 218 } 219 220 const suffix = prefix.slice(completeOn.length); 221 222 if (insertPreview) { 223 repl._insertString(suffix); 224 return; 225 } 226 227 completionPreview = suffix; 228 229 const result = repl.useColors ? 230 `\u001b[90m${suffix}\u001b[39m` : 231 ` // ${suffix}`; 232 233 const { cursorPos, displayPos } = getPreviewPos(); 234 if (repl.line.length !== repl.cursor) { 235 cursorTo(repl.output, displayPos.cols); 236 moveCursor(repl.output, 0, displayPos.rows - cursorPos.rows); 237 } 238 repl.output.write(result); 239 cursorTo(repl.output, cursorPos.cols); 240 const totalLine = `${repl._prompt}${repl.line}${suffix}`; 241 const newPos = repl._getDisplayPos(totalLine); 242 const rows = newPos.rows - cursorPos.rows - (newPos.cols === 0 ? 1 : 0); 243 moveCursor(repl.output, 0, -rows); 244 }); 245 } 246 247 // This returns a code preview for arbitrary input code. 248 function getInputPreview(input, callback) { 249 // For similar reasons as `defaultEval`, wrap expressions starting with a 250 // curly brace with parenthesis. 251 if (input.startsWith('{') && !input.endsWith(';') && !wrapped) { 252 input = `(${input})`; 253 wrapped = true; 254 } 255 sendInspectorCommand((session) => { 256 session.post('Runtime.evaluate', { 257 expression: input, 258 throwOnSideEffect: true, 259 timeout: 333, 260 contextId: repl[contextSymbol], 261 }, (error, preview) => { 262 if (error) { 263 callback(error); 264 return; 265 } 266 const { result } = preview; 267 if (result.value !== undefined) { 268 callback(null, inspect(result.value, previewOptions)); 269 // Ignore EvalErrors, SyntaxErrors and ReferenceErrors. It is not clear 270 // where they came from and if they are recoverable or not. Other errors 271 // may be inspected. 272 } else if (preview.exceptionDetails && 273 (result.className === 'EvalError' || 274 result.className === 'SyntaxError' || 275 result.className === 'ReferenceError')) { 276 callback(null, null); 277 } else if (result.objectId) { 278 // The writer options might change and have influence on the inspect 279 // output. The user might change e.g., `showProxy`, `getters` or 280 // `showHidden`. Use `inspect` instead of `JSON.stringify` to keep 281 // `Infinity` and similar intact. 282 const inspectOptions = inspect({ 283 ...repl.writer.options, 284 colors: false, 285 depth: 1, 286 compact: true, 287 breakLength: Infinity 288 }, previewOptions); 289 session.post('Runtime.callFunctionOn', { 290 functionDeclaration: `(v) => util.inspect(v, ${inspectOptions})`, 291 objectId: result.objectId, 292 arguments: [result] 293 }, (error, preview) => { 294 if (error) { 295 callback(error); 296 } else { 297 callback(null, preview.result.value); 298 } 299 }); 300 } else { 301 // Either not serializable or undefined. 302 callback(null, result.unserializableValue || result.type); 303 } 304 }); 305 }, () => callback(new ERR_INSPECTOR_NOT_AVAILABLE())); 306 } 307 308 const showPreview = () => { 309 // Prevent duplicated previews after a refresh. 310 if (inputPreview !== null || !repl.isCompletionEnabled) { 311 return; 312 } 313 314 const line = repl.line.trim(); 315 316 // Do not preview in case the line only contains whitespace. 317 if (line === '') { 318 return; 319 } 320 321 // Add the autocompletion preview. 322 // TODO(BridgeAR): Trigger the input preview after the completion preview. 323 // That way it's possible to trigger the input prefix including the 324 // potential completion suffix. To do so, we also have to change the 325 // behavior of `enter` and `escape`: 326 // Enter should automatically add the suffix to the current line as long as 327 // escape was not pressed. We might even remove the preview in case any 328 // cursor movement is triggered. 329 const insertPreview = false; 330 showCompletionPreview(repl.line, insertPreview); 331 332 // Do not preview if the command is buffered. 333 if (repl[bufferSymbol]) { 334 return; 335 } 336 337 const inputPreviewCallback = (error, inspected) => { 338 if (inspected === null) { 339 return; 340 } 341 342 wrapped = false; 343 344 // Ignore the output if the value is identical to the current line and the 345 // former preview is not identical to this preview. 346 if (line === inspected && lastInputPreview !== inspected) { 347 return; 348 } 349 350 if (error) { 351 debug('Error while generating preview', error); 352 return; 353 } 354 // Do not preview `undefined` if colors are deactivated or explicitly 355 // requested. 356 if (inspected === 'undefined' && 357 (!repl.useColors || repl.ignoreUndefined)) { 358 return; 359 } 360 361 inputPreview = inspected; 362 363 // Limit the output to maximum 250 characters. Otherwise it becomes a) 364 // difficult to read and b) non terminal REPLs would visualize the whole 365 // output. 366 let maxColumns = MathMin(repl.columns, 250); 367 368 // Support unicode characters of width other than one by checking the 369 // actual width. 370 if (inspected.length * 2 >= maxColumns && 371 getStringWidth(inspected) > maxColumns) { 372 maxColumns -= 4 + (repl.useColors ? 0 : 3); 373 let res = ''; 374 for (const char of inspected) { 375 maxColumns -= getStringWidth(char); 376 if (maxColumns < 0) 377 break; 378 res += char; 379 } 380 inspected = `${res}...`; 381 } 382 383 // Line breaks are very rare and probably only occur in case of error 384 // messages with line breaks. 385 const lineBreakPos = inspected.indexOf('\n'); 386 if (lineBreakPos !== -1) { 387 inspected = `${inspected.slice(0, lineBreakPos)}`; 388 } 389 390 const result = repl.useColors ? 391 `\u001b[90m${inspected}\u001b[39m` : 392 `// ${inspected}`; 393 394 const { cursorPos, displayPos } = getPreviewPos(); 395 const rows = displayPos.rows - cursorPos.rows; 396 moveCursor(repl.output, 0, rows); 397 repl.output.write(`\n${result}`); 398 cursorTo(repl.output, cursorPos.cols); 399 moveCursor(repl.output, 0, -rows - 1); 400 }; 401 402 getInputPreview(line, inputPreviewCallback); 403 if (wrapped) { 404 getInputPreview(line, inputPreviewCallback); 405 } 406 wrapped = false; 407 }; 408 409 // -------------------------------------------------------------------------// 410 // Replace multiple interface functions. This is required to fully support // 411 // previews without changing readlines behavior. // 412 // -------------------------------------------------------------------------// 413 414 // Refresh prints the whole screen again and the preview will be removed 415 // during that procedure. Print the preview again. This also makes sure 416 // the preview is always correct after resizing the terminal window. 417 const originalRefresh = repl._refreshLine.bind(repl); 418 repl._refreshLine = () => { 419 inputPreview = null; 420 originalRefresh(); 421 showPreview(); 422 }; 423 424 let insertCompletionPreview = true; 425 // Insert the longest common suffix of the current input in case the user 426 // moves to the right while already being at the current input end. 427 const originalMoveCursor = repl._moveCursor.bind(repl); 428 repl._moveCursor = (dx) => { 429 const currentCursor = repl.cursor; 430 originalMoveCursor(dx); 431 if (currentCursor + dx > repl.line.length && 432 typeof repl.completer === 'function' && 433 insertCompletionPreview) { 434 const insertPreview = true; 435 showCompletionPreview(repl.line, insertPreview); 436 } 437 }; 438 439 // This is the only function that interferes with the completion insertion. 440 // Monkey patch it to prevent inserting the completion when it shouldn't be. 441 const originalClearLine = repl.clearLine.bind(repl); 442 repl.clearLine = () => { 443 insertCompletionPreview = false; 444 originalClearLine(); 445 insertCompletionPreview = true; 446 }; 447 448 return { showPreview, clearPreview }; 449} 450 451function setupReverseSearch(repl) { 452 // Simple terminals can't use reverse search. 453 if (process.env.TERM === 'dumb') { 454 return { reverseSearch() { return false; } }; 455 } 456 457 const alreadyMatched = new Set(); 458 const labels = { 459 r: 'bck-i-search: ', 460 s: 'fwd-i-search: ' 461 }; 462 let isInReverseSearch = false; 463 let historyIndex = -1; 464 let input = ''; 465 let cursor = -1; 466 let dir = 'r'; 467 let lastMatch = -1; 468 let lastCursor = -1; 469 let promptPos; 470 471 function checkAndSetDirectionKey(keyName) { 472 if (!labels[keyName]) { 473 return false; 474 } 475 if (dir !== keyName) { 476 // Reset the already matched set in case the direction is changed. That 477 // way it's possible to find those entries again. 478 alreadyMatched.clear(); 479 dir = keyName; 480 } 481 return true; 482 } 483 484 function goToNextHistoryIndex() { 485 // Ignore this entry for further searches and continue to the next 486 // history entry. 487 alreadyMatched.add(repl.history[historyIndex]); 488 historyIndex += dir === 'r' ? 1 : -1; 489 cursor = -1; 490 } 491 492 function search() { 493 // Just print an empty line in case the user removed the search parameter. 494 if (input === '') { 495 print(repl.line, `${labels[dir]}_`); 496 return; 497 } 498 // Fix the bounds in case the direction has changed in the meanwhile. 499 if (dir === 'r') { 500 if (historyIndex < 0) { 501 historyIndex = 0; 502 } 503 } else if (historyIndex >= repl.history.length) { 504 historyIndex = repl.history.length - 1; 505 } 506 // Check the history entries until a match is found. 507 while (historyIndex >= 0 && historyIndex < repl.history.length) { 508 let entry = repl.history[historyIndex]; 509 // Visualize all potential matches only once. 510 if (alreadyMatched.has(entry)) { 511 historyIndex += dir === 'r' ? 1 : -1; 512 continue; 513 } 514 // Match the next entry either from the start or from the end, depending 515 // on the current direction. 516 if (dir === 'r') { 517 // Update the cursor in case it's necessary. 518 if (cursor === -1) { 519 cursor = entry.length; 520 } 521 cursor = entry.lastIndexOf(input, cursor - 1); 522 } else { 523 cursor = entry.indexOf(input, cursor + 1); 524 } 525 // Match not found. 526 if (cursor === -1) { 527 goToNextHistoryIndex(); 528 // Match found. 529 } else { 530 if (repl.useColors) { 531 const start = entry.slice(0, cursor); 532 const end = entry.slice(cursor + input.length); 533 entry = `${start}\x1B[4m${input}\x1B[24m${end}`; 534 } 535 print(entry, `${labels[dir]}${input}_`, cursor); 536 lastMatch = historyIndex; 537 lastCursor = cursor; 538 // Explicitly go to the next history item in case no further matches are 539 // possible with the current entry. 540 if ((dir === 'r' && cursor === 0) || 541 (dir === 's' && entry.length === cursor + input.length)) { 542 goToNextHistoryIndex(); 543 } 544 return; 545 } 546 } 547 print(repl.line, `failed-${labels[dir]}${input}_`); 548 } 549 550 function print(outputLine, inputLine, cursor = repl.cursor) { 551 // TODO(BridgeAR): Resizing the terminal window hides the overlay. To fix 552 // that, readline must be aware of this information. It's probably best to 553 // add a couple of properties to readline that allow to do the following: 554 // 1. Add arbitrary data to the end of the current line while not counting 555 // towards the line. This would be useful for the completion previews. 556 // 2. Add arbitrary extra lines that do not count towards the regular line. 557 // This would be useful for both, the input preview and the reverse 558 // search. It might be combined with the first part? 559 // 3. Add arbitrary input that is "on top" of the current line. That is 560 // useful for the reverse search. 561 // 4. To trigger the line refresh, functions should be used to pass through 562 // the information. Alternatively, getters and setters could be used. 563 // That might even be more elegant. 564 // The data would then be accounted for when calling `_refreshLine()`. 565 // This function would then look similar to: 566 // repl.overlay(outputLine); 567 // repl.addTrailingLine(inputLine); 568 // repl.setCursor(cursor); 569 // More potential improvements: use something similar to stream.cork(). 570 // Multiple cursor moves on the same tick could be prevented in case all 571 // writes from the same tick are combined and the cursor is moved at the 572 // tick end instead of after each operation. 573 let rows = 0; 574 if (lastMatch !== -1) { 575 const line = repl.history[lastMatch].slice(0, lastCursor); 576 rows = repl._getDisplayPos(`${repl._prompt}${line}`).rows; 577 cursorTo(repl.output, promptPos.cols); 578 } else if (isInReverseSearch && repl.line !== '') { 579 rows = repl.getCursorPos().rows; 580 cursorTo(repl.output, promptPos.cols); 581 } 582 if (rows !== 0) 583 moveCursor(repl.output, 0, -rows); 584 585 if (isInReverseSearch) { 586 clearScreenDown(repl.output); 587 repl.output.write(`${outputLine}\n${inputLine}`); 588 } else { 589 repl.output.write(`\n${inputLine}`); 590 } 591 592 lastMatch = -1; 593 594 // To know exactly how many rows we have to move the cursor back we need the 595 // cursor rows, the output rows and the input rows. 596 const prompt = repl._prompt; 597 const cursorLine = `${prompt}${outputLine.slice(0, cursor)}`; 598 const cursorPos = repl._getDisplayPos(cursorLine); 599 const outputPos = repl._getDisplayPos(`${prompt}${outputLine}`); 600 const inputPos = repl._getDisplayPos(inputLine); 601 const inputRows = inputPos.rows - (inputPos.cols === 0 ? 1 : 0); 602 603 rows = -1 - inputRows - (outputPos.rows - cursorPos.rows); 604 605 moveCursor(repl.output, 0, rows); 606 cursorTo(repl.output, cursorPos.cols); 607 } 608 609 function reset(string) { 610 isInReverseSearch = string !== undefined; 611 612 // In case the reverse search ends and a history entry is found, reset the 613 // line to the found entry. 614 if (!isInReverseSearch) { 615 if (lastMatch !== -1) { 616 repl.line = repl.history[lastMatch]; 617 repl.cursor = lastCursor; 618 repl.historyIndex = lastMatch; 619 } 620 621 lastMatch = -1; 622 623 // Clear screen and write the current repl.line before exiting. 624 cursorTo(repl.output, promptPos.cols); 625 moveCursor(repl.output, 0, promptPos.rows); 626 clearScreenDown(repl.output); 627 if (repl.line !== '') { 628 repl.output.write(repl.line); 629 if (repl.line.length !== repl.cursor) { 630 const { cols, rows } = repl.getCursorPos(); 631 cursorTo(repl.output, cols); 632 moveCursor(repl.output, 0, rows); 633 } 634 } 635 } 636 637 input = string || ''; 638 cursor = -1; 639 historyIndex = repl.historyIndex; 640 alreadyMatched.clear(); 641 } 642 643 function reverseSearch(string, key) { 644 if (!isInReverseSearch) { 645 if (key.ctrl && checkAndSetDirectionKey(key.name)) { 646 historyIndex = repl.historyIndex; 647 promptPos = repl._getDisplayPos(`${repl._prompt}`); 648 print(repl.line, `${labels[dir]}_`); 649 isInReverseSearch = true; 650 } 651 } else if (key.ctrl && checkAndSetDirectionKey(key.name)) { 652 search(); 653 } else if (key.name === 'backspace' || 654 (key.ctrl && (key.name === 'h' || key.name === 'w'))) { 655 reset(input.slice(0, input.length - 1)); 656 search(); 657 // Special handle <ctrl> + c and escape. Those should only cancel the 658 // reverse search. The original line is visible afterwards again. 659 } else if ((key.ctrl && key.name === 'c') || key.name === 'escape') { 660 lastMatch = -1; 661 reset(); 662 return true; 663 // End search in case either enter is pressed or if any non-reverse-search 664 // key (combination) is pressed. 665 } else if (key.ctrl || 666 key.meta || 667 key.name === 'return' || 668 key.name === 'enter' || 669 typeof string !== 'string' || 670 string === '') { 671 reset(); 672 repl[kSubstringSearch] = ''; 673 } else { 674 reset(`${input}${string}`); 675 search(); 676 } 677 return isInReverseSearch; 678 } 679 680 return { reverseSearch }; 681} 682 683module.exports = { 684 isRecoverableError, 685 kStandaloneREPL: Symbol('kStandaloneREPL'), 686 setupPreview, 687 setupReverseSearch 688}; 689