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
15 changes: 14 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, automatic indentation, 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,18 @@ undefined
undefined
```

### Auto-indentation

<!-- YAML
added: REPLACEME
-->

When entering
multi-line input (e.g., a function body or an object literal), the REPL
automatically indents continuation lines based on the current nesting
depth of braces (`{}`), brackets (`[]`), and parentheses (`()`). The REPL
uses 2-space indentation following the Node.js coding convention.

### Reverse-i-search

<!-- YAML
Expand Down
52 changes: 52 additions & 0 deletions lib/internal/repl/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,57 @@ function isRecoverableError(e, code) {
}
}

const kIndent = ' '; // 2-space indentation (Node.js convention)

/**
* Returns the appropriate indentation string for the next line
* based on the nesting depth of the buffered command.
* Uses the Acorn tokenizer to count unmatched braces, brackets,
* and parentheses. On incomplete input the tokenizer throws, but
* all bracket tokens have been collected by that point.
* @param {string} code The buffered command so far.
* @returns {string} The indentation string (empty if depth <= 0).
*/
function getAutoIndent(code) {
if (!code) return '';

const {
tokTypes: tt,
tokenizer: createTokenizer,
} = require('internal/deps/acorn/acorn/dist/acorn');

let depth = 0;
try {
for (const token of createTokenizer(code, {
ecmaVersion: 'latest',
allowAwaitOutsideFunction: true,
allowImportExportEverywhere: true,
allowReturnOutsideFunction: true,
allowSuperOutsideMethod: true,
})) {
if (token.type === tt.braceL || token.type === tt.bracketL ||
token.type === tt.parenL) {
depth++;
} else if (token.type === tt.braceR || token.type === tt.bracketR ||
token.type === tt.parenR) {
depth--;
}
}
} catch {
// Incomplete input throws, but depth from tokens collected
// before the error is still valid.
}

if (depth <= 0) return '';

let indent = '';
for (let i = 0; i < depth; i++) {
indent += kIndent;
}
return indent;
}


function setupPreview(repl, contextSymbol, bufferSymbol, active) {
// Simple terminals can't handle previews.
if (process.env.TERM === 'dumb' || !active) {
Expand Down Expand Up @@ -867,6 +918,7 @@ module.exports = {
isRecoverableError,
kStandaloneREPL: Symbol('kStandaloneREPL'),
setupPreview,
getAutoIndent,
setupReverseSearch,
isObjectLiteral,
isValidSyntax,
Expand Down
8 changes: 8 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,
getAutoIndent,
setupReverseSearch,
kContextId,
getREPLResourceName,
Expand Down Expand Up @@ -620,6 +621,12 @@ class REPLServer extends Interface {
if (e instanceof Recoverable && !sawCtrlD) {
if (self.terminal) {
self[kAddNewLineOnTTY]();
const buffered = self[kBufferedCommandSymbol] + cmd + '\n';
const indent = getAutoIndent(buffered);
if (indent) {
self._insertString(indent);
}

} else {
self[kBufferedCommandSymbol] += cmd + '\n';
self.displayPrompt();
Expand Down Expand Up @@ -676,6 +683,7 @@ class REPLServer extends Interface {

const { reverseSearch } = setupReverseSearch(this);


const {
clearPreview, showPreview,
} = setupPreview(
Expand Down
249 changes: 249 additions & 0 deletions test/parallel/test-repl-auto-indent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
'use strict';

// Flags: --expose-internals

const common = require('../common');
const assert = require('assert');
const stream = require('stream');
const REPL = require('internal/repl');

const tmpdir = require('../common/tmpdir');
tmpdir.refresh();

const defaultHistoryPath = tmpdir.resolve('.node_repl_history');

// Create an input stream specialized for testing an array of actions.
class ActionStream extends stream.Stream {
run(data) {
const _iter = data[Symbol.iterator]();
const doAction = () => {
const next = _iter.next();
if (next.done) {
// Close the repl. Note that it must have a clean prompt to do so.
this.emit('keypress', '', { ctrl: true, name: 'd' });
return;
}
const action = next.value;

if (typeof action === 'object') {
this.emit('keypress', '', action);
} else {
this.emit('data', `${action}`);
}
setImmediate(doAction);
};
doAction();
}
resume() {}
pause() {}
}
ActionStream.prototype.readable = true;

// Mock keys.
const ENTER = { name: 'enter' };
const prompt = '> ';

// ---------------------------------------------------------------------------
// Test 1: Basic auto-indent after opening brace.
//
// Typing `function f() {` + Enter should trigger a Recoverable error (the
// expression is incomplete) and enter multiline mode. The continuation line
// should be indented with 2 spaces.
// ---------------------------------------------------------------------------
const tests = [
{
name: 'basic auto-indent after opening brace',
env: { NODE_REPL_HISTORY: defaultHistoryPath },
test: (function*() {
yield 'function f() {';
yield ENTER;
// At this point the REPL should have auto-indented.
// Type the body and close the function.
yield 'return 1;';
yield ENTER;
yield '}';
yield ENTER;
})(),
check: common.mustCall(function(outputChunks) {
const combined = outputChunks.join('');
// After the opening brace and Enter, the output should contain the
// continuation prompt followed by indentation (2 spaces).
// The continuation prompt in Node REPL is '... ' by default, followed
// by the auto-indent spaces. Look for at least 2 spaces of indentation
// after the continuation prompt.
assert.ok(
combined.includes(' return') || combined.includes(' return'),
`Expected auto-indented 'return' in output, got: ${JSON.stringify(combined)}`
);
}),
},

// ---------------------------------------------------------------------------
// Test 2: De-indent when typing a closing brace.
//
// After auto-indenting inside a block, typing `}` should remove the
// indentation on the current line so the closing brace aligns with the
// opening statement.
// ---------------------------------------------------------------------------
{
name: 'de-indent on closing brace',
env: { NODE_REPL_HISTORY: defaultHistoryPath },
test: (function*() {
yield 'if (true) {';
yield ENTER;
yield 'x = 1;';
yield ENTER;
yield '}';
yield ENTER;
})(),
check: common.mustCall(function(outputChunks) {
const combined = outputChunks.join('');
// The closing brace `}` should appear without leading indentation
// (or at the base level), while `x = 1;` should be indented.
const lines = combined.split(/[\r\n]+/);
const indentedLine = lines.find((l) => l.includes('x = 1'));
const closingLine = lines.find((l) => {
const stripped = l.replaceAll('\x1b[', '').replace(/[0-9;]*m/g, '');
return /^\s*\.*\s*\}$/.test(stripped);
});
if (indentedLine && closingLine) {
const indentedSpaces = indentedLine.match(/^[\s.]*/)[0].length;
const closingSpaces = closingLine.match(/^[\s.]*/)[0].length;
assert.ok(
closingSpaces <= indentedSpaces,
`Closing brace indent (${closingSpaces}) should be <= indented body (${indentedSpaces})`
);
}
}),
},

// ---------------------------------------------------------------------------
// Test 3: Nested indentation.
//
// An `if` block inside a `function` should be indented by 4 spaces
// (2 levels of 2-space indent).
// ---------------------------------------------------------------------------
{
name: 'nested auto-indent',
env: { NODE_REPL_HISTORY: defaultHistoryPath },
test: (function*() {
yield 'function g() {';
yield ENTER;
yield 'if (true) {';
yield ENTER;
yield 'return 42;';
yield ENTER;
yield '}';
yield ENTER;
yield '}';
yield ENTER;
})(),
check: common.mustCall(function(outputChunks) {
const combined = outputChunks.join('');
// The nested `return 42;` should be indented by 4 spaces (two levels).
// We verify that the output contains the string with leading spaces.
assert.ok(
combined.includes(' return 42') || combined.includes(' return 42'),
`Expected nested indentation (4 spaces) for 'return 42', got: ${JSON.stringify(combined)}`
);
}),
},
];

// ---------------------------------------------------------------------------
// Test 4: Auto-indent disabled on non-TTY (terminal: false).
//
// When the REPL is not running in a terminal, auto-indent should not be
// active. The continuation lines should not have extra indentation.
// ---------------------------------------------------------------------------
{
const outputChunks = [];

REPL.createInternalRepl(

Check failure on line 162 in test/parallel/test-repl-auto-indent.js

View workflow job for this annotation

GitHub Actions / aarch64-darwin: with shared libraries / build

--- stderr --- node:internal/repl/inspector:255 throw new ERR_INSPECTOR_NOT_AVAILABLE(); ^ Error [ERR_INSPECTOR_NOT_AVAILABLE]: Inspector is not available at getReplInspector (node:internal/repl/inspector:255:11) at createReplEval (node:internal/repl/eval:79:21) at new REPLServer (node:repl:334:15) at Object.start (node:repl:1038:10) at Object.createRepl [as createInternalRepl] (node:internal/repl:53:21) at Object.<anonymous> (/Users/runner/work/_temp/node-v27.0.0-nightly2026-07-19147951c255-slim/test/parallel/test-repl-auto-indent.js:162:8) at Module._compile (node:internal/modules/cjs/loader:1937:14) at Object..js (node:internal/modules/cjs/loader:2077:10) at Module.load (node:internal/modules/cjs/loader:1659:32) at Module._load (node:internal/modules/cjs/loader:1451:12) { code: 'ERR_INSPECTOR_NOT_AVAILABLE' } Node.js v27.0.0-pre Command: out/Release/node --expose-internals /Users/runner/work/_temp/node-v27.0.0-nightly2026-07-19147951c255-slim/test/parallel/test-repl-auto-indent.js

Check failure on line 162 in test/parallel/test-repl-auto-indent.js

View workflow job for this annotation

GitHub Actions / x86_64-darwin: with shared libraries / build

--- stderr --- node:internal/repl/inspector:255 throw new ERR_INSPECTOR_NOT_AVAILABLE(); ^ Error [ERR_INSPECTOR_NOT_AVAILABLE]: Inspector is not available at getReplInspector (node:internal/repl/inspector:255:11) at createReplEval (node:internal/repl/eval:79:21) at new REPLServer (node:repl:334:15) at Object.start (node:repl:1038:10) at Object.createRepl [as createInternalRepl] (node:internal/repl:53:21) at Object.<anonymous> (/Users/runner/work/_temp/node-v27.0.0-nightly2026-07-19147951c255-slim/test/parallel/test-repl-auto-indent.js:162:8) at Module._compile (node:internal/modules/cjs/loader:1937:14) at Object..js (node:internal/modules/cjs/loader:2077:10) at Module.load (node:internal/modules/cjs/loader:1659:32) at Module._load (node:internal/modules/cjs/loader:1451:12) { code: 'ERR_INSPECTOR_NOT_AVAILABLE' } Node.js v27.0.0-pre Command: out/Release/node --expose-internals /Users/runner/work/_temp/node-v27.0.0-nightly2026-07-19147951c255-slim/test/parallel/test-repl-auto-indent.js
{ NODE_REPL_HISTORY: defaultHistoryPath },
{
input: new ActionStream(),
output: new stream.Writable({
write(chunk, _encoding, cb) {
outputChunks.push(chunk.toString());
cb();
},
}),
prompt,
useColors: false,
useGlobal: false,
terminal: false, // No TTY ⇒ no auto-indent.
},
common.mustSucceed((repl) => {

repl.once('close', common.mustCall(() => {
const combined = outputChunks.join('');
// In non-terminal mode the REPL simply buffers commands; there is no
// auto-indent inserted. Verify no leading spaces before 'return'.
const lines = combined.split('\n');
const returnLine = lines.find((l) => l.includes('return 1'));
if (returnLine) {
// Strip the prompt/continuation prompt before checking indent.
const stripped = returnLine
.replaceAll('\x1b[', '').replace(/[0-9;]*m/g, '') // Remove ANSI codes.
.replace(/^[>.\s]*\s/, ''); // Remove prompt chars.
assert.ok(
!stripped.startsWith(' '),
`Non-TTY mode should not auto-indent, got: ${JSON.stringify(returnLine)}`
);
}
}));

// Run the multiline input through the non-terminal REPL.
const actions = (function*() {
yield 'function f() {';
yield ENTER;
yield 'return 1;';
yield ENTER;
yield '}';
yield ENTER;
})();
repl.input.run(actions);
// Non-TTY REPL doesn't handle keypress Ctrl+D; close explicitly.
setImmediate(() => repl.close());
}),
);
}

// ---------------------------------------------------------------------------
// Run the TTY-based tests sequentially.
// ---------------------------------------------------------------------------
const numTests = tests.length;
let testIndex = 0;

function runTest() {
const opts = tests[testIndex++];
if (!opts) return;

const outputChunks = [];

REPL.createInternalRepl(opts.env, {
input: new ActionStream(),
output: new stream.Writable({
write(chunk, _encoding, cb) {
outputChunks.push(chunk.toString());
cb();
},
}),
prompt,
useColors: false,
useGlobal: false,
terminal: true,
}, common.mustSucceed((repl) => {

repl.once('close', common.mustCall(() => {
console.log(`Running auto-indent test ${testIndex}/${numTests}: ${opts.name}`);
opts.check(outputChunks);
runTest();
}));

repl.input.run(opts.test);
}));
}

runTest();
Loading