Skip to content

Commit 63e25ed

Browse files
committed
repl: add auto-indentation for multiline input
Automatically insert 2-space indentation on continuation lines based on brace/bracket/paren depth when entering multiline input. Refs: #48164 Signed-off-by: hemanth <hemanth.hm@gmail.com>
1 parent c286592 commit 63e25ed

4 files changed

Lines changed: 324 additions & 1 deletion

File tree

doc/api/repl.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ 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, automatic indentation, simplistic Emacs-style line editing, multi-line inputs,
3131
[ZSH][]-like reverse-i-search, [ZSH][]-like substring-based history search,
3232
ANSI-styled output, saving and restoring current REPL session state, error
3333
recovery, and customizable evaluation functions. Terminals that do not support
@@ -232,6 +232,18 @@ undefined
232232
undefined
233233
```
234234

235+
### Auto-indentation
236+
237+
<!-- YAML
238+
added: REPLACEME
239+
-->
240+
241+
When entering
242+
multi-line input (e.g., a function body or an object literal), the REPL
243+
automatically indents continuation lines based on the current nesting
244+
depth of braces (`{}`), brackets (`[]`), and parentheses (`()`). The REPL
245+
uses 2-space indentation following the Node.js coding convention.
246+
235247
### Reverse-i-search
236248

237249
<!-- YAML

lib/internal/repl/utils.js

Lines changed: 54 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,
@@ -143,6 +144,58 @@ function isRecoverableError(e, code) {
143144
}
144145
}
145146

147+
const kIndent = ' '; // 2-space indentation (Node.js convention)
148+
149+
/**
150+
* Returns the appropriate indentation string for the next line
151+
* based on the nesting depth of the buffered command.
152+
* Uses the Acorn tokenizer to count unmatched braces, brackets,
153+
* and parentheses. On incomplete input the tokenizer throws, but
154+
* all bracket tokens have been collected by that point.
155+
* @param {string} code The buffered command so far.
156+
* @returns {string} The indentation string (empty if depth <= 0).
157+
*/
158+
function getAutoIndent(code) {
159+
if (!code) return '';
160+
161+
const {
162+
tokTypes: tt,
163+
tokenizer: createTokenizer,
164+
} = require('internal/deps/acorn/acorn/dist/acorn');
165+
166+
let depth = 0;
167+
try {
168+
for (const token of createTokenizer(code, {
169+
ecmaVersion: 'latest',
170+
allowAwaitOutsideFunction: true,
171+
allowImportExportEverywhere: true,
172+
allowReturnOutsideFunction: true,
173+
allowSuperOutsideMethod: true,
174+
})) {
175+
if (token.type === tt.braceL || token.type === tt.bracketL ||
176+
token.type === tt.parenL) {
177+
depth++;
178+
} else if (token.type === tt.braceR || token.type === tt.bracketR ||
179+
token.type === tt.parenR) {
180+
depth--;
181+
}
182+
}
183+
} catch {
184+
// Incomplete input throws, but depth from tokens collected
185+
// before the error is still valid.
186+
}
187+
188+
if (depth <= 0) return '';
189+
190+
let indent = '';
191+
for (let i = 0; i < depth; i++) {
192+
indent += kIndent;
193+
}
194+
return indent;
195+
}
196+
197+
198+
146199
function setupPreview(repl, contextSymbol, bufferSymbol, active) {
147200
// Simple terminals can't handle previews.
148201
if (process.env.TERM === 'dumb' || !active) {
@@ -867,6 +920,7 @@ module.exports = {
867920
isRecoverableError,
868921
kStandaloneREPL: Symbol('kStandaloneREPL'),
869922
setupPreview,
923+
getAutoIndent,
870924
setupReverseSearch,
871925
isObjectLiteral,
872926
isValidSyntax,

lib/repl.js

Lines changed: 8 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+
getAutoIndent,
130131
setupReverseSearch,
131132
kContextId,
132133
getREPLResourceName,
@@ -620,6 +621,12 @@ class REPLServer extends Interface {
620621
if (e instanceof Recoverable && !sawCtrlD) {
621622
if (self.terminal) {
622623
self[kAddNewLineOnTTY]();
624+
const buffered = self[kBufferedCommandSymbol] + cmd + '\n';
625+
const indent = getAutoIndent(buffered);
626+
if (indent) {
627+
self._insertString(indent);
628+
}
629+
623630
} else {
624631
self[kBufferedCommandSymbol] += cmd + '\n';
625632
self.displayPrompt();
@@ -676,6 +683,7 @@ class REPLServer extends Interface {
676683

677684
const { reverseSearch } = setupReverseSearch(this);
678685

686+
679687
const {
680688
clearPreview, showPreview,
681689
} = setupPreview(
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
'use strict';
2+
3+
// Flags: --expose-internals
4+
5+
const common = require('../common');
6+
const assert = require('assert');
7+
const stream = require('stream');
8+
const REPL = require('internal/repl');
9+
10+
const tmpdir = require('../common/tmpdir');
11+
tmpdir.refresh();
12+
13+
const defaultHistoryPath = tmpdir.resolve('.node_repl_history');
14+
15+
// Create an input stream specialized for testing an array of actions.
16+
class ActionStream extends stream.Stream {
17+
run(data) {
18+
const _iter = data[Symbol.iterator]();
19+
const doAction = () => {
20+
const next = _iter.next();
21+
if (next.done) {
22+
// Close the repl. Note that it must have a clean prompt to do so.
23+
this.emit('keypress', '', { ctrl: true, name: 'd' });
24+
return;
25+
}
26+
const action = next.value;
27+
28+
if (typeof action === 'object') {
29+
this.emit('keypress', '', action);
30+
} else {
31+
this.emit('data', `${action}`);
32+
}
33+
setImmediate(doAction);
34+
};
35+
doAction();
36+
}
37+
resume() {}
38+
pause() {}
39+
}
40+
ActionStream.prototype.readable = true;
41+
42+
// Mock keys.
43+
const ENTER = { name: 'enter' };
44+
const prompt = '> ';
45+
46+
// ---------------------------------------------------------------------------
47+
// Test 1: Basic auto-indent after opening brace.
48+
//
49+
// Typing `function f() {` + Enter should trigger a Recoverable error (the
50+
// expression is incomplete) and enter multiline mode. The continuation line
51+
// should be indented with 2 spaces.
52+
// ---------------------------------------------------------------------------
53+
const tests = [
54+
{
55+
name: 'basic auto-indent after opening brace',
56+
env: { NODE_REPL_HISTORY: defaultHistoryPath },
57+
test: (function*() {
58+
yield 'function f() {';
59+
yield ENTER;
60+
// At this point the REPL should have auto-indented.
61+
// Type the body and close the function.
62+
yield 'return 1;';
63+
yield ENTER;
64+
yield '}';
65+
yield ENTER;
66+
})(),
67+
check: common.mustCall(function(outputChunks) {
68+
const combined = outputChunks.join('');
69+
// After the opening brace and Enter, the output should contain the
70+
// continuation prompt followed by indentation (2 spaces).
71+
// The continuation prompt in Node REPL is '... ' by default, followed
72+
// by the auto-indent spaces. Look for at least 2 spaces of indentation
73+
// after the continuation prompt.
74+
assert.ok(
75+
combined.includes(' return') || combined.includes(' return'),
76+
`Expected auto-indented 'return' in output, got: ${JSON.stringify(combined)}`
77+
);
78+
}),
79+
},
80+
81+
// ---------------------------------------------------------------------------
82+
// Test 2: De-indent when typing a closing brace.
83+
//
84+
// After auto-indenting inside a block, typing `}` should remove the
85+
// indentation on the current line so the closing brace aligns with the
86+
// opening statement.
87+
// ---------------------------------------------------------------------------
88+
{
89+
name: 'de-indent on closing brace',
90+
env: { NODE_REPL_HISTORY: defaultHistoryPath },
91+
test: (function*() {
92+
yield 'if (true) {';
93+
yield ENTER;
94+
yield 'x = 1;';
95+
yield ENTER;
96+
yield '}';
97+
yield ENTER;
98+
})(),
99+
check: common.mustCall(function(outputChunks) {
100+
const combined = outputChunks.join('');
101+
// The closing brace `}` should appear without leading indentation
102+
// (or at the base level), while `x = 1;` should be indented.
103+
const lines = combined.split(/[\r\n]+/);
104+
const indentedLine = lines.find((l) => l.includes('x = 1'));
105+
const closingLine = lines.find((l) => {
106+
const stripped = l.replaceAll('\x1b[', '').replace(/[0-9;]*m/g, '');
107+
return /^\s*\.*\s*\}$/.test(stripped);
108+
});
109+
if (indentedLine && closingLine) {
110+
const indentedSpaces = indentedLine.match(/^[\s.]*/)[0].length;
111+
const closingSpaces = closingLine.match(/^[\s.]*/)[0].length;
112+
assert.ok(
113+
closingSpaces <= indentedSpaces,
114+
`Closing brace indent (${closingSpaces}) should be <= indented body (${indentedSpaces})`
115+
);
116+
}
117+
}),
118+
},
119+
120+
// ---------------------------------------------------------------------------
121+
// Test 3: Nested indentation.
122+
//
123+
// An `if` block inside a `function` should be indented by 4 spaces
124+
// (2 levels of 2-space indent).
125+
// ---------------------------------------------------------------------------
126+
{
127+
name: 'nested auto-indent',
128+
env: { NODE_REPL_HISTORY: defaultHistoryPath },
129+
test: (function*() {
130+
yield 'function g() {';
131+
yield ENTER;
132+
yield 'if (true) {';
133+
yield ENTER;
134+
yield 'return 42;';
135+
yield ENTER;
136+
yield '}';
137+
yield ENTER;
138+
yield '}';
139+
yield ENTER;
140+
})(),
141+
check: common.mustCall(function(outputChunks) {
142+
const combined = outputChunks.join('');
143+
// The nested `return 42;` should be indented by 4 spaces (two levels).
144+
// We verify that the output contains the string with leading spaces.
145+
assert.ok(
146+
combined.includes(' return 42') || combined.includes(' return 42'),
147+
`Expected nested indentation (4 spaces) for 'return 42', got: ${JSON.stringify(combined)}`
148+
);
149+
}),
150+
},
151+
];
152+
153+
// ---------------------------------------------------------------------------
154+
// Test 4: Auto-indent disabled on non-TTY (terminal: false).
155+
//
156+
// When the REPL is not running in a terminal, auto-indent should not be
157+
// active. The continuation lines should not have extra indentation.
158+
// ---------------------------------------------------------------------------
159+
{
160+
const outputChunks = [];
161+
162+
REPL.createInternalRepl(
163+
{ NODE_REPL_HISTORY: defaultHistoryPath },
164+
{
165+
input: new ActionStream(),
166+
output: new stream.Writable({
167+
write(chunk, _encoding, cb) {
168+
outputChunks.push(chunk.toString());
169+
cb();
170+
},
171+
}),
172+
prompt,
173+
useColors: false,
174+
useGlobal: false,
175+
terminal: false, // No TTY ⇒ no auto-indent.
176+
},
177+
common.mustSucceed((repl) => {
178+
179+
repl.once('close', common.mustCall(() => {
180+
const combined = outputChunks.join('');
181+
// In non-terminal mode the REPL simply buffers commands; there is no
182+
// auto-indent inserted. Verify no leading spaces before 'return'.
183+
const lines = combined.split('\n');
184+
const returnLine = lines.find((l) => l.includes('return 1'));
185+
if (returnLine) {
186+
// Strip the prompt/continuation prompt before checking indent.
187+
const stripped = returnLine
188+
.replaceAll('\x1b[', '').replace(/[0-9;]*m/g, '') // Remove ANSI codes.
189+
.replace(/^[>.\s]*\s/, ''); // Remove prompt chars.
190+
assert.ok(
191+
!stripped.startsWith(' '),
192+
`Non-TTY mode should not auto-indent, got: ${JSON.stringify(returnLine)}`
193+
);
194+
}
195+
}));
196+
197+
// Run the multiline input through the non-terminal REPL.
198+
const actions = (function*() {
199+
yield 'function f() {';
200+
yield ENTER;
201+
yield 'return 1;';
202+
yield ENTER;
203+
yield '}';
204+
yield ENTER;
205+
})();
206+
repl.input.run(actions);
207+
// Non-TTY REPL doesn't handle keypress Ctrl+D; close explicitly.
208+
setImmediate(() => repl.close());
209+
}),
210+
);
211+
}
212+
213+
// ---------------------------------------------------------------------------
214+
// Run the TTY-based tests sequentially.
215+
// ---------------------------------------------------------------------------
216+
const numTests = tests.length;
217+
let testIndex = 0;
218+
219+
function runTest() {
220+
const opts = tests[testIndex++];
221+
if (!opts) return;
222+
223+
const outputChunks = [];
224+
225+
REPL.createInternalRepl(opts.env, {
226+
input: new ActionStream(),
227+
output: new stream.Writable({
228+
write(chunk, _encoding, cb) {
229+
outputChunks.push(chunk.toString());
230+
cb();
231+
},
232+
}),
233+
prompt,
234+
useColors: false,
235+
useGlobal: false,
236+
terminal: true,
237+
}, common.mustSucceed((repl) => {
238+
239+
repl.once('close', common.mustCall(() => {
240+
console.log(`Running auto-indent test ${testIndex}/${numTests}: ${opts.name}`);
241+
opts.check(outputChunks);
242+
runTest();
243+
}));
244+
245+
repl.input.run(opts.test);
246+
}));
247+
}
248+
249+
runTest();

0 commit comments

Comments
 (0)