current commit
260725.7 · repl: add basic syntax highlighting
import * as common from '../common/index.mjs';
import assert from 'node:assert';
import events from 'node:events';
import { REPLServer } from 'node:repl';
import { Stream } from 'node:stream';
import { inspect } from 'node:util';

common.skipIfInspectorDisabled();

// Ignore terminal settings. This is so the test can be run intact if TERM=dumb.
process.env.TERM = '';
const PROMPT = 'repl > ';

class REPLStream extends Stream {
  readable = true;
  writable = true;

  constructor() {
    super();
    this.lines = [''];
    this.writeCount = 0;
  }
  async run(data) {
    for (const entry of data) {
      this.emit('data', entry);
      await this.settle();
    }
    this.emit('data', '\n');
  }
  settle() {
    return new Promise((resolve) => {
      let last = this.writeCount;
      let quiet = 0;
      const check = () => {
        if (this.writeCount !== last) {
          last = this.writeCount;
          quiet = 0;
        } else if (++quiet >= 10) {
          resolve();
          return;
        }
        setImmediate(check);
      };
      setImmediate(check);
    });
  }
  write(chunk) {
    this.writeCount++;
    const chunkLines = chunk.toString('utf8').split('\n');
    this.lines[this.lines.length - 1] += chunkLines[0];
    if (chunkLines.length > 1) {
      this.lines.push(...chunkLines.slice(1));
    }
    this.emit('line', this.lines[this.lines.length - 1]);
    return true;
  }
  async wait() {
    this.lines = [''];
    for await (const [line] of events.on(this, 'line')) {
      if (line.includes(PROMPT)) {
        return this.lines;
      }
    }
  }
  pause() {}
  resume() {}
}

async function runAndWait(cmds, repl) {
  const promise = repl.inputStream.wait();
  for (const cmd of cmds) {
    await repl.inputStream.run(cmd);
  }
  return promise;
}

function stripStackFrames(lines) {
  return lines.filter((line) => !/^\s*at /.test(line));
}

async function tests(options) {
  const repl = new REPLServer({
    prompt: PROMPT,
    stream: new REPLStream(),
    ignoreUndefined: true,
    // Keep syntax highlighting out of these preview transcript assertions.
    // Preview and result colors are enabled after readline is initialized.
    useColors: false,
    ...options
  });
  repl.useColors = true;
  repl.writer.options.colors = true;

  await runAndWait([
    'function foo(x) { return x; } ' +
    'function koo() { console.log("abc"); } ' +
    'a = undefined;',
  ], repl);

  const testCases = [{
    input: 'foo',
    noPreview: '[Function: foo]',
    preview: [
      'foo',
      '\x1B[90m[Function: foo]\x1B[39m\x1B[11G\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r',
      '\x1B[36m[Function: foo]\x1B[39m',
    ]
  }, {
    input: 'koo',
    noPreview: '[Function: koo]',
    preview: [
      'k\x1B[90moo\x1B[39m\x1B[9G',
      '\x1B[90m[Function: koo]\x1B[39m\x1B[9G\x1B[1A\x1B[1B\x1B[2K\x1B[1A' +
        '\x1B[0Ko\x1B[90mo\x1B[39m\x1B[10G',
      '\x1B[90m[Function: koo]\x1B[39m\x1B[10G\x1B[1A\x1B[1B\x1B[2K\x1B[1A' +
        '\x1B[0Ko',
      '\x1B[90m[Function: koo]\x1B[39m\x1B[11G\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r',
      '\x1B[36m[Function: koo]\x1B[39m',
    ]
  }, {
    input: 'a',
    noPreview: 'repl > ', // No "undefined" output.
    preview: ['a\r'] // No "undefined" preview.
  }, {
    input: " { b: 1 }['b'] === 1",
    noPreview: '\x1B[33mtrue\x1B[39m',
    preview: [
      " { b: 1 }['b']",
      '\x1B[90m1\x1B[39m\x1B[22G\x1B[1A\x1B[1B\x1B[2K\x1B[1A ',
      '\x1B[90m1\x1B[39m\x1B[23G\x1B[1A\x1B[1B\x1B[2K\x1B[1A=== 1',
      '\x1B[90mtrue\x1B[39m\x1B[28G\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r',
      '\x1B[33mtrue\x1B[39m',
    ]
  }, {
    input: "{ b: 1 }['b'] === 1;",
    noPreview: '\x1B[33mfalse\x1B[39m',
    preview: [
      "{ b: 1 }['b']",
      '\x1B[90m1\x1B[39m\x1B[21G\x1B[1A\x1B[1B\x1B[2K\x1B[1A ',
      '\x1B[90m1\x1B[39m\x1B[22G\x1B[1A\x1B[1B\x1B[2K\x1B[1A=== 1',
      '\x1B[90mtrue\x1B[39m\x1B[27G\x1B[1A\x1B[1B\x1B[2K\x1B[1A;',
      '\x1B[90mfalse\x1B[39m\x1B[28G\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r',
      '\x1B[33mfalse\x1B[39m',
    ]
  }, {
    input: '{ a: true }',
    noPreview: '{ a: \x1B[33mtrue\x1B[39m }',
    preview: [
      '{ a: tru\x1B[90me\x1B[39m\x1B[16G\x1B[0Ke }\r',
      '{ a: \x1B[33mtrue\x1B[39m }',
    ]
  }, {
    input: '{ a: true };',
    noPreview: '\x1B[33mtrue\x1B[39m',
    preview: [
      '{ a: tru\x1B[90me\x1B[39m\x1B[16G\x1B[0Ke };',
      '\x1B[90mtrue\x1B[39m\x1B[20G\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r',
      '\x1B[33mtrue\x1B[39m',
    ]
  }, {
    input: ' \t { a: true};',
    noPreview: '\x1B[33mtrue\x1B[39m',
    preview: [
      '  { a: tru\x1B[90me\x1B[39m\x1B[18G\x1B[0Ke}',
      '\x1B[90m{ a: true }\x1B[39m\x1B[20G\x1B[1A\x1B[1B\x1B[2K\x1B[1A;',
      '\x1B[90mtrue\x1B[39m\x1B[21G\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r',
      '\x1B[33mtrue\x1B[39m',
    ]
  }, {
    input: '1n + 2n',
    noPreview: '\x1B[33m3n\x1B[39m',
    preview: [
      '1n + 2',
      '\x1B[90mType[39m\x1B[14G\x1B[1A\x1B[1B\x1B[2K\x1B[1An',
      '\x1B[90m3n\x1B[39m\x1B[15G\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r',
      '\x1B[33m3n\x1B[39m',
    ]
  }, {
    // The inspector evaluator treats `{};1` as a block statement followed by a
    // labelled/expression statement and rejects the trailing expression. The
    // eager-eval preview still shows the result of the wrapped expression (`1`),
    // but the committed evaluation reports a SyntaxError.
    input: '{};1',
    noPreview: 'Uncaught SyntaxError: Unexpected token \';\'',
    preview: [
      '{};1',
      '\x1B[90m1\x1B[39m\x1B[12G\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r',
      'Uncaught SyntaxError: Unexpected token \';\'',
    ]
  }, {
    input: 'aaaa',
    noPreview: 'Uncaught ReferenceError: aaaa is not defined',
    preview: [
      'aaaa\r',
      'Uncaught ReferenceError: aaaa is not defined',
    ]
  }, {
    input: '/0',
    noPreview: 'Uncaught SyntaxError: Invalid regular expression: missing /',
    preview: [
      '/0\r',
      'Uncaught SyntaxError: Invalid regular expression: missing /',
    ]
  }, {
    input: '{})',
    noPreview: "Uncaught SyntaxError: Unexpected token ')'",
    preview: [
      '{})\r',
      "Uncaught SyntaxError: Unexpected token ')'",
    ],
  }, {
    input: "{ a: '{' }",
    noPreview: "{ a: \x1B[32m'{'\x1B[39m }",
    preview: [
      "{ a: '{' }\r",
      "{ a: \x1B[32m'{'\x1B[39m }",
    ],
  }, {
    input: "{'{':0}",
    noPreview: "{ \x1B[32m'{'\x1B[39m: \x1B[33m0\x1B[39m }",
    preview: [
      "{'{':0}",
      "\x1B[90m{ '{': 0 }\x1B[39m\x1B[15G\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r",
      "{ \x1B[32m'{'\x1B[39m: \x1B[33m0\x1B[39m }",
    ],
  }, {
    input: '{[Symbol.for("{")]: 0 }',
    noPreview: '{ \x1B[32mSymbol({)\x1B[39m: \x1B[33m0\x1B[39m }',
    preview: [
      '{[Sym\x1B[90mbol\x1B[39m\x1B[13G\x1B[0Kb\x1B[90mol\x1B[39m\x1B[14G\x1B[0Ko' +
        '\x1B[90ml\x1B[39m\x1B[15G\x1B[0Kl.f\x1B[90mor\x1B[39m\x1B[18G\x1B[0Ko' +
        '\x1B[90mr\x1B[39m\x1B[19G\x1B[0Kr("{")]: 0 }\r',
      '{ \x1B[32mSymbol({)\x1B[39m: \x1B[33m0\x1B[39m }',
    ],
  }, {
    input: '{},{}',
    noPreview: '{}',
    preview: [
      '{},{}',
      '\x1B[90m{}\x1B[39m\x1B[13G\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r',
      '{}',
    ],
  }, {
    input: '{} //;',
    noPreview: 'repl > ',
    preview: [
      '{} //;\r',
    ],
  }, {
    // The inspector evaluator wraps the input in parentheses to detect an
    // expression, which turns `{throw 0}` into invalid syntax. The eager-eval
    // preview still shows the thrown value, but the committed evaluation reports
    // a SyntaxError.
    input: '{throw 0}',
    noPreview: 'Uncaught SyntaxError: Unexpected number',
    preview: [
      // Typing `{thr` offers the `throw` keyword as an inline completion preview.
      '{thr\x1B[90mow\x1B[39m\x1B[12G\x1B[0Ko\x1B[90mw\x1B[39m\x1B[13G\x1B[0Kw 0}',
      '\x1B[90m0\x1B[39m\x1B[17G\x1B[1A\x1B[1B\x1B[2K\x1B[1A\r',
      'Uncaught SyntaxError: Unexpected number',
    ],
  }];

  const hasPreview = repl.terminal &&
    (options.preview !== undefined ? !!options.preview : true);

  for (const { input, noPreview, preview } of testCases) {
    console.log(`Testing ${input}`);

    const toBeRun = input.split('\n');
    let lines = await runAndWait(toBeRun, repl);

    // Drop unstable inspector stack frames (see `stripStackFrames`).
    lines = stripStackFrames(lines);

    if (hasPreview) {
      // Remove error messages. That allows the code to run in different
      // engines.
      // eslint-disable-next-line no-control-regex
      lines = lines.map((line) => line.replace(/Error: .+?\x1B/, ''));
      assert.strictEqual(lines.pop(), '\x1B[1G\x1B[0Jrepl > \x1B[8G');
      assert.deepStrictEqual(lines, preview);
    } else {
      assert.ok(lines[0].includes(noPreview), lines.map(inspect).join('\n'));
      if (preview.length !== 1 || preview[0] !== `${input}\r`) {
        assert.strictEqual(lines.length, 2);
      }
    }
  }

  repl.close();
}

// NOTE: The input `{} //` (a block followed by a line comment, with no trailing
// semicolon) is intentionally NOT exercised here. Under the inspector-based
// evaluator it is treated as an incomplete statement: the REPL emits the
// continuation prompt (`| `) and waits for more input forever instead of
// completing. This is a regression from the previous vm-based REPL, where the
// same input evaluated to `undefined` and completed. See the test report.

await tests({ terminal: false }); // No preview
await tests({ terminal: true }); // Preview
await tests({ terminal: false, preview: false }); // No preview
await tests({ terminal: false, preview: true }); // No preview
await tests({ terminal: true, preview: true }); // Preview