Skip to content

Commit 1eb1502

Browse files
committed
repl: add basic syntax highlighting
Signed-off-by: avivkeller <me@aviv.sh>
1 parent 4f844f4 commit 1eb1502

5 files changed

Lines changed: 166 additions & 5 deletions

File tree

lib/internal/readline/interface.js

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

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

188191
if (input.tabSize !== undefined) {
189192
validateUint32(input.tabSize, 'tabSize', true);
@@ -514,15 +517,18 @@ class Interface extends InterfaceConstructor {
514517
if (this[kIsMultiline]) {
515518
const lines = StringPrototypeSplit(this.line, '\n');
516519
// Write first line with normal prompt
517-
this[kWriteToOutput](this[kPrompt] + lines[0]);
520+
this[kWriteToOutput](this[kPrompt] +
521+
(this[kColorize]?.(lines[0]) ?? lines[0]));
518522

519523
// For continuation lines, add the "|" prefix
520524
for (let i = 1; i < lines.length; i++) {
521-
this[kWriteToOutput](`\n${kMultilinePrompt.description}` + lines[i]);
525+
this[kWriteToOutput](`\n${kMultilinePrompt.description}` +
526+
(this[kColorize]?.(lines[i]) ?? lines[i]));
522527
}
523528
} else {
524529
// Write the prompt and the current buffer content.
525-
this[kWriteToOutput](line);
530+
this[kWriteToOutput](this[kPrompt] +
531+
(this[kColorize]?.(this.line) ?? this.line));
526532
}
527533

528534
// Force terminal to allocate a new line
@@ -671,7 +677,11 @@ class Interface extends InterfaceConstructor {
671677
this.line += c;
672678
}
673679
this.cursor += c.length;
674-
this[kWriteToOutput](c);
680+
if (this[kColorize] === undefined) {
681+
this[kWriteToOutput](c);
682+
} else {
683+
this[kRefreshLine]();
684+
}
675685
return;
676686
}
677687
if (this.cursor < this.line.length) {
@@ -690,7 +700,7 @@ class Interface extends InterfaceConstructor {
690700
this.cursor += c.length;
691701
const newPos = this.getCursorPos();
692702

693-
if (oldPos.rows < newPos.rows) {
703+
if (oldPos.rows < newPos.rows || this[kColorize] !== undefined) {
694704
this[kRefreshLine]();
695705
} else {
696706
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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Flags: --expose-internals
2+
'use strict';
3+
4+
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+
assert.strictEqual(stripVTControlCharacters(highlight(
12+
'const /* comment */ answer = 42; // comment',
13+
)), 'const /* comment */ answer = 42; // comment');
14+
15+
assert.strictEqual(
16+
highlight('const value = "text"'),
17+
`\u001b[${inspect.colors.cyan[0]}mconst\u001b[${inspect.colors.cyan[1]}m value = ` +
18+
`\u001b[${inspect.colors.green[0]}m"text"\u001b[${inspect.colors.green[1]}m`,
19+
);
20+
21+
for (const source of [
22+
'undefined',
23+
'NaN',
24+
'Infinity',
25+
'Symbol.iterator',
26+
'Date.now()',
27+
'true',
28+
'null',
29+
'1n',
30+
'/regexp/u',
31+
'import("node:fs")',
32+
]) {
33+
assert.notStrictEqual(highlight(source), source);
34+
}
35+
36+
// Incomplete source is expected while the user is typing.
37+
highlight('const value = "');
38+
39+
{
40+
const input = new ArrayStream();
41+
const output = new ArrayStream();
42+
let rendered = '';
43+
output.write = (chunk) => {
44+
rendered += chunk;
45+
};
46+
47+
const repl = new REPLServer({
48+
input,
49+
output,
50+
prompt: '',
51+
terminal: true,
52+
useColors: false,
53+
});
54+
rendered = '';
55+
repl.write('const');
56+
57+
assert.strictEqual(rendered, 'const');
58+
repl.close();
59+
}

0 commit comments

Comments
 (0)