Skip to content

Commit 4a5eb1c

Browse files
committed
repl: add basic syntax highlighting
Signed-off-by: avivkeller <me@aviv.sh> PR-URL: #64591 Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent 1ba3ce4 commit 4a5eb1c

9 files changed

Lines changed: 199 additions & 17 deletions

File tree

lib/internal/readline/interface.js

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,15 @@ const kPreviousCursorCols = Symbol('_previousCursorCols');
157157
const kMultilineMove = Symbol('_multilineMove');
158158
const kPreviousPrevRows = Symbol('_previousPrevRows');
159159
const kAddNewLineOnTTY = Symbol('_addNewLineOnTTY');
160+
const kColorize = Symbol('_colorize');
160161

161162
function InterfaceConstructor(input, output, completer, terminal) {
162163
this[kSawReturnAt] = 0;
163164
// TODO(BridgeAR): Document this property. The name is not ideal, so we
164165
// might want to expose an alias and document that instead.
165166
this.isCompletionEnabled = true;
166167
this[kSawKeyPress] = false;
168+
this[kColorize] = undefined;
167169
this[kPreviousKey] = null;
168170
this.escapeCodeTimeout = ESCAPE_CODE_TIMEOUT;
169171
this.tabSize = 8;
@@ -186,6 +188,7 @@ function InterfaceConstructor(input, output, completer, terminal) {
186188
const historySize = input.historySize;
187189
const history = input.history;
188190
const removeHistoryDuplicates = input.removeHistoryDuplicates;
191+
this[kColorize] = input.colorize;
189192

190193
if (input.tabSize !== undefined) {
191194
validateUint32(input.tabSize, 'tabSize', true);
@@ -523,15 +526,18 @@ class Interface extends InterfaceConstructor {
523526
if (this[kIsMultiline]) {
524527
const lines = StringPrototypeSplit(this.line, '\n');
525528
// Write first line with normal prompt
526-
this[kWriteToOutput](this[kPrompt] + lines[0]);
529+
this[kWriteToOutput](this[kPrompt] +
530+
(this[kColorize]?.(lines[0]) ?? lines[0]));
527531

528532
// For continuation lines, add the "|" prefix
529533
for (let i = 1; i < lines.length; i++) {
530-
this[kWriteToOutput](`\n${kMultilinePrompt.description}` + lines[i]);
534+
this[kWriteToOutput](`\n${kMultilinePrompt.description}` +
535+
(this[kColorize]?.(lines[i]) ?? lines[i]));
531536
}
532537
} else {
533538
// Write the prompt and the current buffer content.
534-
this[kWriteToOutput](line);
539+
this[kWriteToOutput](this[kPrompt] +
540+
(this[kColorize]?.(this.line) ?? this.line));
535541
}
536542

537543
// Force terminal to allocate a new line
@@ -687,7 +693,11 @@ class Interface extends InterfaceConstructor {
687693
this.line += c;
688694
}
689695
this.cursor += c.length;
690-
this[kWriteToOutput](c);
696+
if (this[kColorize] === undefined) {
697+
this[kWriteToOutput](c);
698+
} else {
699+
this[kRefreshLine]();
700+
}
691701
return;
692702
}
693703
if (this.cursor < this.line.length) {
@@ -706,7 +716,7 @@ class Interface extends InterfaceConstructor {
706716
this.cursor += c.length;
707717
const newPos = this.getCursorPos();
708718

709-
if (oldPos.rows < newPos.rows) {
719+
if (oldPos.rows < newPos.rows || this[kColorize] !== undefined) {
710720
this[kRefreshLine]();
711721
} else {
712722
this[kWriteToOutput](c);

lib/internal/repl/highlight.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
'use strict';
2+
3+
const {
4+
FunctionPrototypeBind,
5+
StringPrototypeSlice,
6+
} = primordials;
7+
8+
const { stylizeWithColor } = require('internal/util/inspect');
9+
const { Parser } = require('internal/deps/acorn/acorn/dist/acorn');
10+
11+
const tokenizer = FunctionPrototypeBind(Parser.tokenizer, Parser);
12+
13+
function tokenStyle(token) {
14+
const { label, keyword } = token.type;
15+
16+
if (keyword !== undefined) {
17+
if (label === 'true' || label === 'false') return 'boolean';
18+
if (label === 'null') return 'null';
19+
if (label === 'import' || label === 'export') return 'module';
20+
return 'special';
21+
}
22+
23+
switch (label) {
24+
case 'name':
25+
switch (token.value) {
26+
case 'undefined':
27+
return 'undefined';
28+
case 'NaN':
29+
case 'Infinity':
30+
return 'number';
31+
case 'Symbol':
32+
return 'symbol';
33+
case 'Date':
34+
return 'date';
35+
default:
36+
return undefined;
37+
}
38+
case 'num':
39+
return 'number';
40+
case 'bigint':
41+
return 'bigint';
42+
case 'string':
43+
case 'template':
44+
case '`':
45+
return 'string';
46+
case 'regexp':
47+
return 'regexp';
48+
default:
49+
return undefined;
50+
}
51+
}
52+
53+
function highlight(code) {
54+
if (code.length === 0) return code;
55+
56+
let result = '';
57+
let offset = 0;
58+
function write(start, end, style) {
59+
result +=
60+
StringPrototypeSlice(code, offset, start) +
61+
stylizeWithColor(StringPrototypeSlice(code, start, end), style);
62+
offset = end;
63+
}
64+
65+
try {
66+
const iterator = tokenizer(code, {
67+
__proto__: null,
68+
allowHashBang: true,
69+
ecmaVersion: 'latest',
70+
onComment(_block, _text, start, end) {
71+
write(start, end, 'undefined');
72+
},
73+
});
74+
75+
for (const token of iterator) {
76+
const style = tokenStyle(token);
77+
if (style !== undefined) {
78+
write(token.start, token.end, style);
79+
}
80+
}
81+
} catch {
82+
// Acorn throws for unfinished strings, templates, and comments. Tokens and
83+
// comments reported before that point are still useful while typing.
84+
}
85+
86+
return result + StringPrototypeSlice(code, offset);
87+
}
88+
89+
module.exports = { highlight };

lib/internal/util/inspect.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3057,6 +3057,7 @@ module.exports = {
30573057
identicalSequenceRange,
30583058
inspect,
30593059
inspectDefaultOptions,
3060+
stylizeWithColor,
30603061
format,
30613062
formatWithOptions,
30623063
getStringWidth,

lib/repl.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ const {
9898
} = require('internal/readline/utils');
9999
const { Console } = require('console');
100100
const { shouldColorize } = require('internal/util/colors');
101+
const { highlight } = require('internal/repl/highlight');
101102
const CJSModule = require('internal/modules/cjs/loader').Module;
102103
const { AsyncLocalStorage } = require('async_hooks');
103104
let debug = require('internal/util/debuglog').debuglog('repl', (fn) => {
@@ -246,6 +247,7 @@ class REPLServer extends Interface {
246247
terminal: options.terminal,
247248
historySize: options.historySize,
248249
prompt,
250+
colorize: options.useColors ? highlight : undefined,
249251
});
250252

251253
ObjectDefineProperty(this, 'inputStream', {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Flags: --expose-internals
2+
'use strict';
3+
4+
const common = require('../common');
5+
const assert = require('assert');
6+
const { inspect, stripVTControlCharacters } = require('util');
7+
const ArrayStream = require('../common/arraystream');
8+
const { highlight } = require('internal/repl/highlight');
9+
const { REPLServer } = require('repl');
10+
11+
common.skipIfInspectorDisabled();
12+
13+
assert.strictEqual(stripVTControlCharacters(highlight(
14+
'const /* comment */ answer = 42; // comment',
15+
)), 'const /* comment */ answer = 42; // comment');
16+
17+
assert.strictEqual(
18+
highlight('const value = "text"'),
19+
`\u001b[${inspect.colors.cyan[0]}mconst\u001b[${inspect.colors.cyan[1]}m value = ` +
20+
`\u001b[${inspect.colors.green[0]}m"text"\u001b[${inspect.colors.green[1]}m`,
21+
);
22+
23+
for (const source of [
24+
'undefined',
25+
'NaN',
26+
'Infinity',
27+
'Symbol.iterator',
28+
'Date.now()',
29+
'true',
30+
'null',
31+
'1n',
32+
'/regexp/u',
33+
'import("node:fs")',
34+
]) {
35+
assert.notStrictEqual(highlight(source), source);
36+
}
37+
38+
// Incomplete source is expected while the user is typing.
39+
highlight('const value = "');
40+
41+
{
42+
const input = new ArrayStream();
43+
const output = new ArrayStream();
44+
let rendered = '';
45+
output.write = (chunk) => {
46+
rendered += chunk;
47+
};
48+
49+
const repl = new REPLServer({
50+
input,
51+
output,
52+
prompt: '',
53+
terminal: true,
54+
useColors: false,
55+
});
56+
rendered = '';
57+
repl.write('const');
58+
59+
assert.strictEqual(rendered, 'const');
60+
repl.close();
61+
}

test/parallel/test-repl-multiline.js

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
const common = require('../common');
33
const assert = require('assert');
44
const { startNewREPLServer } = require('../common/repl');
5+
const { stripVTControlCharacters } = require('util');
56

67
const input = ['const foo = {', '};', 'foo'];
78

@@ -10,16 +11,16 @@ async function run({ useColors }) {
1011

1112
await write(input);
1213

13-
const actual = output.accumulator.split('\n');
14+
// The output contains various escape codes, including
15+
// screen clears and others, so we trim them all to
16+
// simplify this test.
17+
const actual = stripVTControlCharacters(output.accumulator);
1418

15-
// Validate the output, which contains terminal escape codes.
16-
assert.strictEqual(actual.length, 6);
17-
assert.ok(actual[0].endsWith(input[0]));
18-
assert.ok(actual[1].includes('| '));
19-
assert.ok(actual[1].endsWith(input[1]));
20-
assert.ok(actual[2].includes('undefined'));
21-
assert.ok(actual[3].endsWith(input[2]));
22-
assert.strictEqual(actual[4], '{}');
19+
const firstStatementIdx = actual.indexOf(input.slice(0, 1).join('\n| '));
20+
assert(firstStatementIdx > -1);
21+
22+
assert(actual.slice(firstStatementIdx).includes('undefined'));
23+
assert(actual.slice(firstStatementIdx).includes('foo'));
2324

2425
replServer.on('exit', common.mustCall());
2526
replServer.close();

test/parallel/test-repl-preview-newlines.mjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import { startNewREPLServer } from '../common/repl.js';
44

55
common.skipIfInspectorDisabled();
66

7-
const { output, run, replServer } = startNewREPLServer({ useColors: true });
7+
// Ignore terminal settings so the preview remains active under TERM=dumb.
8+
process.env.TERM = '';
9+
10+
// Keep syntax highlighting out of this test so it only covers preview layout.
11+
// Preview and result colors are enabled after readline is initialized.
12+
const { output, run, replServer } = startNewREPLServer({ useColors: false });
13+
replServer.useColors = true;
14+
replServer.writer.options.colors = true;
815

916
for (const char of ['\\n', '\\v', '\\r']) {
1017
output.accumulator = '';

test/parallel/test-repl-preview.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,13 @@ async function tests(options) {
8383
prompt: PROMPT,
8484
stream: new REPLStream(),
8585
ignoreUndefined: true,
86-
useColors: true,
86+
// Keep syntax highlighting out of these preview transcript assertions.
87+
// Preview and result colors are enabled after readline is initialized.
88+
useColors: false,
8789
...options
8890
});
91+
repl.useColors = true;
92+
repl.writer.options.colors = true;
8993

9094
await runAndWait([
9195
'function foo(x) { return x; } ' +

test/parallel/test-repl-reverse-search.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,9 @@ function runTest() {
324324
}),
325325
completer: opts.completer,
326326
prompt,
327-
useColors: opts.useColors || false,
327+
// Keep syntax highlighting out of the reverse-search transcript. Result
328+
// colors are enabled below after readline has been initialized.
329+
useColors: false,
328330
terminal: true
329331
}, common.mustCall((err, repl) => {
330332
if (err) {
@@ -346,6 +348,11 @@ function runTest() {
346348
setImmediate(runTestWrap, true);
347349
}));
348350

351+
if (opts.useColors) {
352+
repl.useColors = true;
353+
repl.writer.options.colors = true;
354+
}
355+
349356
if (opts.columns) {
350357
Object.defineProperty(repl, 'columns', {
351358
value: opts.columns,

0 commit comments

Comments
 (0)