Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion doc/api/repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ result. Input and output may be from `stdin` and `stdout`, respectively, or may
be connected to any Node.js [stream][].

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

### Function signature hints

The REPL provides a visual hint of a function's parameters when typing its
name followed by an open parenthesis `(`. This hint is displayed below the
input line.

```console
> console.log(
// console.log(...data)
```

Requires the `preview` feature to be enabled and the inspector to be available.

### Reverse-i-search

<!-- YAML
Expand Down
126 changes: 126 additions & 0 deletions lib/internal/repl/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
RegExpPrototypeExec,
SafeSet,
SafeStringIterator,
StringPrototypeCharCodeAt,
StringPrototypeIndexOf,
StringPrototypeLastIndexOf,
StringPrototypeReplaceAll,
Expand Down Expand Up @@ -51,6 +52,8 @@ const CJSModule = require('internal/modules/cjs/loader').Module;

const vm = require('vm');

const { styleText } = require('util');

let debug = require('internal/util/debuglog').debuglog('repl', (fn) => {
debug = fn;
});
Expand Down Expand Up @@ -861,12 +864,135 @@ function setReplBuiltinLibs(value) {
_builtinLibs = value;
}

const kStyleOpts = { __proto__: null, validateStream: false };

/**
* Sets up function signature hints. When the user types a function
* name followed by '(', a dimmed hint showing the function's
* parameters is displayed below the input.
*
* Preconditions (checked by the caller in repl.js):
* - TERM is not 'dumb'
* - preview is active
* - terminal is true
* - inspector is available
* @param {REPLServer} repl The REPL instance.
* @param {symbol} contextSymbol The context ID symbol.
* @returns {object} An object with showSignatureHint and clearSignatureHint methods.
*/
function setupSignatureHint(repl, contextSymbol) {
const clearLineOutput = clearLine;
const cursorToOutput = cursorTo;
const moveCursorOutput = moveCursor;

let currentHint = null;

function clearSignatureHint() {
if (currentHint === null) return;

const { cursorPos, displayPos } = getPreviewPos();
// Move to the line below the input.
const rows = displayPos.rows - cursorPos.rows + 1;
moveCursorOutput(repl.output, 0, rows);
clearLineOutput(repl.output);
moveCursorOutput(repl.output, 0, -rows);
currentHint = null;
}

function getPreviewPos() {
const displayPos =
repl._getDisplayPos(`${repl.getPrompt()}${repl.line}`);
const cursorPos = repl.line.length !== repl.cursor ?
repl.getCursorPos() : displayPos;
return { displayPos, cursorPos };
}

function showSignatureHint() {
if (currentHint !== null) return;

const line = repl.line;
const cursor = repl.cursor;

// Find the function name before the opening parenthesis.
// Look backwards from cursor for pattern: identifier(
let parenPos = -1;
for (let i = cursor - 1; i >= 0; i--) {
const ch = StringPrototypeCharCodeAt(line, i);
if (ch === 40) { // '('
parenPos = i;
break;
}
// If we hit anything other than whitespace or content after '(',
// stop looking.
if (ch !== 32 && ch !== 9) break; // space, tab
}

if (parenPos < 0) return;

// Extract the expression before the '(' - supports simple names
// (e.g. `foo(`) and dotted access (e.g. `console.log(`).
// Parenthesized expressions like `(fn)(` are not handled;
// the hint is silently skipped in that case.
const nameEnd = parenPos;
let nameStart = nameEnd;
for (let i = nameEnd - 1; i >= 0; i--) {
const ch = StringPrototypeCharCodeAt(line, i);
// Allow identifier chars and dots for property access.
if ((ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) ||
(ch >= 48 && ch <= 57) || ch === 95 || ch === 36 || ch === 46) {
nameStart = i;
} else {
break;
}
}
Comment on lines +916 to +947

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we get the expression for auto completion? Can we reuse that logic here?


if (nameStart >= nameEnd) return;

const funcName = StringPrototypeSlice(line, nameStart, nameEnd);
if (!funcName) return;

// Use the inspector's evaluate helper to get the function's params.
const inspector = getReplInspector(repl);
inspector.evaluate({
__proto__: null,
expression: `typeof ${funcName} === 'function' ? ` +
`${funcName}.toString().match(/^[^{=]*\\(([^)]*)\\)/)?.[1] || '' : ''`,
throwOnSideEffect: true,
timeout: 200,
contextId: repl[contextSymbol],
}).then((preview) => {
Comment thread
hemanth marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be cleaner to make this function asynchronous?

if (!preview?.result?.value) return;

const params = preview.result.value;
if (!params) return;

const hint = `${funcName}(${params})`;
currentHint = hint;

const { cursorPos, displayPos } = getPreviewPos();
const rows = displayPos.rows - cursorPos.rows;
moveCursorOutput(repl.output, 0, rows);
const result = repl.useColors ?
`\n${styleText('gray', hint, kStyleOpts)}` :
`\n// ${hint}`;
repl.output.write(result);
cursorToOutput(repl.output, cursorPos.cols);
moveCursorOutput(repl.output, 0, -rows - 1);
}).catch(() => {
// Expected when throwOnSideEffect rejects or inspector is unavailable.
});
}

return { showSignatureHint, clearSignatureHint };
}

module.exports = {
REPL_MODE_SLOPPY: Symbol('repl-sloppy'),
REPL_MODE_STRICT,
isRecoverableError,
kStandaloneREPL: Symbol('kStandaloneREPL'),
setupPreview,
setupSignatureHint,
setupReverseSearch,
isObjectLiteral,
isValidSyntax,
Expand Down
15 changes: 15 additions & 0 deletions lib/repl.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const {
REPL_MODE_STRICT,
kStandaloneREPL,
setupPreview,
setupSignatureHint,
setupReverseSearch,
kContextId,
getREPLResourceName,
Expand Down Expand Up @@ -676,6 +677,16 @@ class REPLServer extends Interface {

const { reverseSearch } = setupReverseSearch(this);

let showSignatureHint;
let clearSignatureHint;
if (preview && this.terminal && process.features.inspector &&
process.env.TERM !== 'dumb') {
({ showSignatureHint, clearSignatureHint } = setupSignatureHint(
this,
kContextId,
));
}
Comment on lines +680 to +688

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than setting functions to no-ops, we can use ?.()


const {
clearPreview, showPreview,
} = setupPreview(
Expand All @@ -700,11 +711,15 @@ class REPLServer extends Interface {
self.cursor === 0 && self.line.length === 0) {
self.clearLine();
}
clearSignatureHint?.();
clearPreview(key);
if (!reverseSearch(d, key)) {
ttyWrite(d, key);
const showCompletionPreview = key.name !== 'escape';
showPreview(showCompletionPreview);
if (d === '(') {
showSignatureHint?.();
}
}
return;
}
Expand Down
175 changes: 175 additions & 0 deletions test/parallel/test-repl-signature-hints.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
'use strict';

const common = require('../common');
common.skipIfInspectorDisabled();

const assert = require('assert');
const { startNewREPLServer } = require('../common/repl');

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

(async () => {
{
// Test 1: Typing `foo(` shows a signature hint with parameters.
const { replServer, input, output, run } = startNewREPLServer({
useColors: false,
});

await run(['function foo(a, b, c) { return a + b + c; }']);
output.accumulator = '';

input.emit('data', 'foo(');
await delay(500);

assert.ok(
output.accumulator.includes('foo(a, b, c)'),
`Expected signature hint "foo(a, b, c)", got:\n${output.accumulator}`
);
replServer.close();
}

{
// Test 2: Zero-param function should not display a hint.
const { replServer, input, output, run } = startNewREPLServer({
useColors: false,
});

await run(['function bar() { return 42; }']);
output.accumulator = '';

input.emit('data', 'bar(');
await delay(500);

assert.ok(
!output.accumulator.includes('// bar()'),
`Did not expect a hint for zero-param function, got:\n${output.accumulator}`
);
replServer.close();
}

{
// Test 3: Dotted access — `console.log(` should show a hint.
const { replServer, input, output } = startNewREPLServer({
useColors: false,
});

output.accumulator = '';
input.emit('data', 'console.log(');
await delay(500);

assert.ok(
output.accumulator.includes('console.log('),
`Expected signature hint for console.log, got:\n${output.accumulator}`
);
replServer.close();
}

{
// Test 4: Non-function should NOT show a hint.
const { replServer, input, output, run } = startNewREPLServer({
useColors: false,
});

await run(['const x = 42']);
output.accumulator = '';

input.emit('data', 'x(');
await delay(500);

assert.ok(
!output.accumulator.includes('// x('),
`Did not expect a hint for a non-function, got:\n${output.accumulator}`
);
replServer.close();
}

{
// Test 5: With useColors, hint should use gray ANSI styling.
const { replServer, input, output, run } = startNewREPLServer({
useColors: true,
});

await run(['function greet(name) { return name; }']);
output.accumulator = '';

input.emit('data', 'greet(');
await delay(500);

const gray = '\x1b[90m';
assert.ok(
output.accumulator.includes(gray) &&
output.accumulator.includes('greet(name)'),
`Expected gray-styled hint for greet(name), got:\n${output.accumulator}`
);
replServer.close();
}

{
// Test 6: preview=false should not show signature hints.
const { replServer, input, output, run } = startNewREPLServer({
useColors: false,
preview: false,
});

await run(['function noHint(z) { return z; }']);
output.accumulator = '';

input.emit('data', 'noHint(');
await delay(500);

assert.ok(
!output.accumulator.includes('noHint(z)'),
`Did not expect a hint with preview=false, got:\n${output.accumulator}`
);
replServer.close();
}

{
// Test 7: Arrow functions should show hints.
const { replServer, input, output, run } = startNewREPLServer({
useColors: false,
});

await run(['const add = (x, y) => x + y']);
output.accumulator = '';

input.emit('data', 'add(');
await delay(500);

assert.ok(
output.accumulator.includes('add(x, y)'),
`Expected signature hint for arrow function, got:\n${output.accumulator}`
);
replServer.close();
}

{
// Test 8: Hint is cleared on next keypress.
const { replServer, input, output, run } = startNewREPLServer({
useColors: false,
});

await run(['function clear(a) { return a; }']);
output.accumulator = '';

input.emit('data', 'clear(');
await delay(500);
assert.ok(
output.accumulator.includes('clear(a)'),
`Expected hint to appear, got:\n${output.accumulator}`
);

// Typing another character should clear the hint.
output.accumulator = '';
input.emit('data', '1');
await delay(200);

// The clear operation writes ANSI escape sequences to erase the hint line.
// After clearing, the hint text should not be re-rendered.
assert.ok(
!output.accumulator.includes('// clear(a)'),
`Expected hint to be cleared after typing, got:\n${output.accumulator}`
);
replServer.close();
}
})().then(common.mustCall());
Loading