• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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