Skip to content

Commit 86623b4

Browse files
committed
repl: add function signature hints
When typing a function name followed by '(', the REPL now displays a dimmed hint below the input line showing the function's parameters. For example, typing 'console.log(' shows: // console.log(...data) Uses the inspector to safely extract parameter lists via Function.prototype.toString(), with throwOnSideEffect to prevent unintended evaluation. PR-URL: #64610
1 parent c286592 commit 86623b4

4 files changed

Lines changed: 331 additions & 1 deletion

File tree

doc/api/repl.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ result. Input and output may be from `stdin` and `stdout`, respectively, or may
2727
be connected to any Node.js [stream][].
2828

2929
Instances of [`repl.REPLServer`][] support automatic completion of inputs,
30-
completion preview, simplistic Emacs-style line editing, multi-line inputs,
30+
completion preview, function signature hints, simplistic Emacs-style line editing,
31+
multi-line inputs,
3132
[ZSH][]-like reverse-i-search, [ZSH][]-like substring-based history search,
3233
ANSI-styled output, saving and restoring current REPL session state, error
3334
recovery, and customizable evaluation functions. Terminals that do not support
@@ -232,6 +233,19 @@ undefined
232233
undefined
233234
```
234235

236+
### Function signature hints
237+
238+
The REPL provides a visual hint of a function's parameters when typing its
239+
name followed by an open parenthesis `(`. This hint is displayed below the
240+
input line.
241+
242+
```console
243+
> console.log(
244+
// console.log(...data)
245+
```
246+
247+
Requires the `preview` feature to be enabled and the inspector to be available.
248+
235249
### Reverse-i-search
236250

237251
<!-- YAML

lib/internal/repl/utils.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
RegExpPrototypeExec,
1212
SafeSet,
1313
SafeStringIterator,
14+
StringPrototypeCharCodeAt,
1415
StringPrototypeIndexOf,
1516
StringPrototypeLastIndexOf,
1617
StringPrototypeReplaceAll,
@@ -51,6 +52,8 @@ const CJSModule = require('internal/modules/cjs/loader').Module;
5152

5253
const vm = require('vm');
5354

55+
const { styleText } = require('util');
56+
5457
let debug = require('internal/util/debuglog').debuglog('repl', (fn) => {
5558
debug = fn;
5659
});
@@ -861,12 +864,135 @@ function setReplBuiltinLibs(value) {
861864
_builtinLibs = value;
862865
}
863866

867+
const kStyleOpts = { __proto__: null, validateStream: false };
868+
869+
/**
870+
* Sets up function signature hints. When the user types a function
871+
* name followed by '(', a dimmed hint showing the function's
872+
* parameters is displayed below the input.
873+
*
874+
* Preconditions (checked by the caller in repl.js):
875+
* - TERM is not 'dumb'
876+
* - preview is active
877+
* - terminal is true
878+
* - inspector is available
879+
* @param {REPLServer} repl The REPL instance.
880+
* @param {symbol} contextSymbol The context ID symbol.
881+
* @returns {object} An object with showSignatureHint and clearSignatureHint methods.
882+
*/
883+
function setupSignatureHint(repl, contextSymbol) {
884+
const clearLineOutput = clearLine;
885+
const cursorToOutput = cursorTo;
886+
const moveCursorOutput = moveCursor;
887+
888+
let currentHint = null;
889+
890+
function clearSignatureHint() {
891+
if (currentHint === null) return;
892+
893+
const { cursorPos, displayPos } = getPreviewPos();
894+
// Move to the line below the input.
895+
const rows = displayPos.rows - cursorPos.rows + 1;
896+
moveCursorOutput(repl.output, 0, rows);
897+
clearLineOutput(repl.output);
898+
moveCursorOutput(repl.output, 0, -rows);
899+
currentHint = null;
900+
}
901+
902+
function getPreviewPos() {
903+
const displayPos =
904+
repl._getDisplayPos(`${repl.getPrompt()}${repl.line}`);
905+
const cursorPos = repl.line.length !== repl.cursor ?
906+
repl.getCursorPos() : displayPos;
907+
return { displayPos, cursorPos };
908+
}
909+
910+
function showSignatureHint() {
911+
if (currentHint !== null) return;
912+
913+
const line = repl.line;
914+
const cursor = repl.cursor;
915+
916+
// Find the function name before the opening parenthesis.
917+
// Look backwards from cursor for pattern: identifier(
918+
let parenPos = -1;
919+
for (let i = cursor - 1; i >= 0; i--) {
920+
const ch = StringPrototypeCharCodeAt(line, i);
921+
if (ch === 40) { // '('
922+
parenPos = i;
923+
break;
924+
}
925+
// If we hit anything other than whitespace or content after '(',
926+
// stop looking.
927+
if (ch !== 32 && ch !== 9) break; // space, tab
928+
}
929+
930+
if (parenPos < 0) return;
931+
932+
// Extract the expression before the '(' - supports simple names
933+
// (e.g. `foo(`) and dotted access (e.g. `console.log(`).
934+
// Parenthesized expressions like `(fn)(` are not handled;
935+
// the hint is silently skipped in that case.
936+
const nameEnd = parenPos;
937+
let nameStart = nameEnd;
938+
for (let i = nameEnd - 1; i >= 0; i--) {
939+
const ch = StringPrototypeCharCodeAt(line, i);
940+
// Allow identifier chars and dots for property access.
941+
if ((ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) ||
942+
(ch >= 48 && ch <= 57) || ch === 95 || ch === 36 || ch === 46) {
943+
nameStart = i;
944+
} else {
945+
break;
946+
}
947+
}
948+
949+
if (nameStart >= nameEnd) return;
950+
951+
const funcName = StringPrototypeSlice(line, nameStart, nameEnd);
952+
if (!funcName) return;
953+
954+
// Use the inspector's evaluate helper to get the function's params.
955+
const inspector = getReplInspector(repl);
956+
inspector.evaluate({
957+
__proto__: null,
958+
expression: `typeof ${funcName} === 'function' ? ` +
959+
`${funcName}.toString().match(/^[^{=]*\\(([^)]*)\\)/)?.[1] || '' : ''`,
960+
throwOnSideEffect: true,
961+
timeout: 200,
962+
contextId: repl[contextSymbol],
963+
}).then((preview) => {
964+
if (!preview?.result?.value) return;
965+
966+
const params = preview.result.value;
967+
if (!params) return;
968+
969+
const hint = `${funcName}(${params})`;
970+
currentHint = hint;
971+
972+
const { cursorPos, displayPos } = getPreviewPos();
973+
const rows = displayPos.rows - cursorPos.rows;
974+
moveCursorOutput(repl.output, 0, rows);
975+
const result = repl.useColors ?
976+
`\n${styleText('gray', hint, kStyleOpts)}` :
977+
`\n// ${hint}`;
978+
repl.output.write(result);
979+
cursorToOutput(repl.output, cursorPos.cols);
980+
moveCursorOutput(repl.output, 0, -rows - 1);
981+
}).catch(() => {
982+
// Expected when throwOnSideEffect rejects or inspector is unavailable.
983+
});
984+
}
985+
986+
return { showSignatureHint, clearSignatureHint };
987+
}
988+
864989
module.exports = {
865990
REPL_MODE_SLOPPY: Symbol('repl-sloppy'),
866991
REPL_MODE_STRICT,
867992
isRecoverableError,
868993
kStandaloneREPL: Symbol('kStandaloneREPL'),
869994
setupPreview,
995+
setupSignatureHint,
870996
setupReverseSearch,
871997
isObjectLiteral,
872998
isValidSyntax,

lib/repl.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ const {
127127
REPL_MODE_STRICT,
128128
kStandaloneREPL,
129129
setupPreview,
130+
setupSignatureHint,
130131
setupReverseSearch,
131132
kContextId,
132133
getREPLResourceName,
@@ -676,6 +677,16 @@ class REPLServer extends Interface {
676677

677678
const { reverseSearch } = setupReverseSearch(this);
678679

680+
let showSignatureHint;
681+
let clearSignatureHint;
682+
if (preview && this.terminal && process.features.inspector &&
683+
process.env.TERM !== 'dumb') {
684+
({ showSignatureHint, clearSignatureHint } = setupSignatureHint(
685+
this,
686+
kContextId,
687+
));
688+
}
689+
679690
const {
680691
clearPreview, showPreview,
681692
} = setupPreview(
@@ -700,11 +711,15 @@ class REPLServer extends Interface {
700711
self.cursor === 0 && self.line.length === 0) {
701712
self.clearLine();
702713
}
714+
clearSignatureHint?.();
703715
clearPreview(key);
704716
if (!reverseSearch(d, key)) {
705717
ttyWrite(d, key);
706718
const showCompletionPreview = key.name !== 'escape';
707719
showPreview(showCompletionPreview);
720+
if (d === '(') {
721+
showSignatureHint?.();
722+
}
708723
}
709724
return;
710725
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
common.skipIfInspectorDisabled();
5+
6+
const assert = require('assert');
7+
const { startNewREPLServer } = require('../common/repl');
8+
9+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10+
11+
(async () => {
12+
{
13+
// Test 1: Typing `foo(` shows a signature hint with parameters.
14+
const { replServer, input, output, run } = startNewREPLServer({
15+
useColors: false,
16+
});
17+
18+
await run(['function foo(a, b, c) { return a + b + c; }']);
19+
output.accumulator = '';
20+
21+
input.emit('data', 'foo(');
22+
await delay(500);
23+
24+
assert.ok(
25+
output.accumulator.includes('foo(a, b, c)'),
26+
`Expected signature hint "foo(a, b, c)", got:\n${output.accumulator}`
27+
);
28+
replServer.close();
29+
}
30+
31+
{
32+
// Test 2: Zero-param function should not display a hint.
33+
const { replServer, input, output, run } = startNewREPLServer({
34+
useColors: false,
35+
});
36+
37+
await run(['function bar() { return 42; }']);
38+
output.accumulator = '';
39+
40+
input.emit('data', 'bar(');
41+
await delay(500);
42+
43+
assert.ok(
44+
!output.accumulator.includes('// bar()'),
45+
`Did not expect a hint for zero-param function, got:\n${output.accumulator}`
46+
);
47+
replServer.close();
48+
}
49+
50+
{
51+
// Test 3: Dotted access — `console.log(` should show a hint.
52+
const { replServer, input, output } = startNewREPLServer({
53+
useColors: false,
54+
});
55+
56+
output.accumulator = '';
57+
input.emit('data', 'console.log(');
58+
await delay(500);
59+
60+
assert.ok(
61+
output.accumulator.includes('console.log('),
62+
`Expected signature hint for console.log, got:\n${output.accumulator}`
63+
);
64+
replServer.close();
65+
}
66+
67+
{
68+
// Test 4: Non-function should NOT show a hint.
69+
const { replServer, input, output, run } = startNewREPLServer({
70+
useColors: false,
71+
});
72+
73+
await run(['const x = 42']);
74+
output.accumulator = '';
75+
76+
input.emit('data', 'x(');
77+
await delay(500);
78+
79+
assert.ok(
80+
!output.accumulator.includes('// x('),
81+
`Did not expect a hint for a non-function, got:\n${output.accumulator}`
82+
);
83+
replServer.close();
84+
}
85+
86+
{
87+
// Test 5: With useColors, hint should use gray ANSI styling.
88+
const { replServer, input, output, run } = startNewREPLServer({
89+
useColors: true,
90+
});
91+
92+
await run(['function greet(name) { return name; }']);
93+
output.accumulator = '';
94+
95+
input.emit('data', 'greet(');
96+
await delay(500);
97+
98+
const gray = '\x1b[90m';
99+
assert.ok(
100+
output.accumulator.includes(gray) &&
101+
output.accumulator.includes('greet(name)'),
102+
`Expected gray-styled hint for greet(name), got:\n${output.accumulator}`
103+
);
104+
replServer.close();
105+
}
106+
107+
{
108+
// Test 6: preview=false should not show signature hints.
109+
const { replServer, input, output, run } = startNewREPLServer({
110+
useColors: false,
111+
preview: false,
112+
});
113+
114+
await run(['function noHint(z) { return z; }']);
115+
output.accumulator = '';
116+
117+
input.emit('data', 'noHint(');
118+
await delay(500);
119+
120+
assert.ok(
121+
!output.accumulator.includes('noHint(z)'),
122+
`Did not expect a hint with preview=false, got:\n${output.accumulator}`
123+
);
124+
replServer.close();
125+
}
126+
127+
{
128+
// Test 7: Arrow functions should show hints.
129+
const { replServer, input, output, run } = startNewREPLServer({
130+
useColors: false,
131+
});
132+
133+
await run(['const add = (x, y) => x + y']);
134+
output.accumulator = '';
135+
136+
input.emit('data', 'add(');
137+
await delay(500);
138+
139+
assert.ok(
140+
output.accumulator.includes('add(x, y)'),
141+
`Expected signature hint for arrow function, got:\n${output.accumulator}`
142+
);
143+
replServer.close();
144+
}
145+
146+
{
147+
// Test 8: Hint is cleared on next keypress.
148+
const { replServer, input, output, run } = startNewREPLServer({
149+
useColors: false,
150+
});
151+
152+
await run(['function clear(a) { return a; }']);
153+
output.accumulator = '';
154+
155+
input.emit('data', 'clear(');
156+
await delay(500);
157+
assert.ok(
158+
output.accumulator.includes('clear(a)'),
159+
`Expected hint to appear, got:\n${output.accumulator}`
160+
);
161+
162+
// Typing another character should clear the hint.
163+
output.accumulator = '';
164+
input.emit('data', '1');
165+
await delay(200);
166+
167+
// The clear operation writes ANSI escape sequences to erase the hint line.
168+
// After clearing, the hint text should not be re-rendered.
169+
assert.ok(
170+
!output.accumulator.includes('// clear(a)'),
171+
`Expected hint to be cleared after typing, got:\n${output.accumulator}`
172+
);
173+
replServer.close();
174+
}
175+
})().then(common.mustCall());

0 commit comments

Comments
 (0)